commit c0f81dbbe28ac1063040375d25e11ea52746665c Author: Nvex Date: Sat Oct 25 11:45:17 2025 +0800 初始化鸿蒙应用展示平台项目 - 前后端分离架构 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94d24a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +.venv +*.egg-info/ +dist/ +build/ + +# Node +node_modules/ +dist/ +.DS_Store +*.local + +# Environment +.env +.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite diff --git a/README.md b/README.md new file mode 100644 index 0000000..472ea0d --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# 鸿蒙应用展示平台 + +基于华为应用市场数据的鸿蒙应用展示平台,采用前后端分离架构,风格简约美观,参考 Apple 设计。 + +## 项目特点 + +- 🎨 简约美观的 Apple 风格设计 +- 📱 完美适配移动端和 PC 端 +- 🎯 磁贴效果展示,统一 8px 圆角 +- 🚀 前后端分离架构 +- ⚡ 基于 Vue 3 + TypeScript + FastAPI + +## 功能模块 + +### 首页 +- **今日上架**: 展示当天上架的应用 +- **热门应用 Top 100**: 按下载量排序的热门应用 +- **高分应用 Top 100**: 按评分排序的优质应用 + +### 应用页面 +- **分类标签**: 展示各个分类的 tag 标签 +- **分类浏览**: 点击标签查看该分类下的应用 +- **搜索功能**: 顶部搜索栏搜索数据库中的所有应用 +- **分页展示**: 支持分页浏览应用列表 + +### 我的页面 +- **个人信息**: 展示登录信息(待开发) +- **隐私政策**: 隐私政策链接 +- **Cookie 使用条款**: Cookie 相关说明 +- **服务条款**: 服务条款链接 + +## 技术栈 + +### 后端 +- Python 3.10+ +- FastAPI - Web 框架 +- SQLAlchemy - ORM +- MySQL 8.0+ - 数据库 +- aiomysql - 异步 MySQL 驱动 + +### 前端 +- Vue 3 - 渐进式框架 +- TypeScript - 类型安全 +- Vite - 构建工具 +- Vue Router - 路由管理 +- Pinia - 状态管理 +- Axios - HTTP 客户端 + +## 快速开始 + +### 环境要求 + +- Python 3.10+ +- Node.js 18+ +- MySQL 8.0+ + +### 后端设置 + +```bash +cd backend + +# 创建虚拟环境 +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 安装依赖 +pip install -r requirements.txt + +# 配置环境变量 +cp .env.example .env +# 编辑 .env 文件,配置数据库连接 + +# 运行服务 +python -m app.main +``` + +后端服务将在 http://localhost:8000 启动 + +### 前端设置 + +```bash +cd frontend + +# 安装依赖 +npm install + +# 配置环境变量 +cp .env.example .env + +# 启动开发服务器 +npm run dev +``` + +前端服务将在 http://localhost:5173 启动 + +### 数据库设置 + +参考 `华为应用市场爬虫系统开发文档.md` 中的数据库设计章节,创建相应的数据表。 + +## 项目结构 + +``` +. +├── backend/ # 后端项目 +│ ├── app/ +│ │ ├── api/ # API 路由 +│ │ ├── models/ # 数据模型 +│ │ ├── schemas/ # Pydantic 模型 +│ │ ├── config.py # 配置文件 +│ │ ├── database.py # 数据库连接 +│ │ └── main.py # 应用入口 +│ ├── requirements.txt # Python 依赖 +│ └── .env.example # 环境变量示例 +│ +├── frontend/ # 前端项目 +│ ├── src/ +│ │ ├── api/ # API 封装 +│ │ ├── assets/ # 静态资源 +│ │ ├── components/ # 组件 +│ │ ├── router/ # 路由配置 +│ │ ├── views/ # 页面 +│ │ ├── App.vue # 根组件 +│ │ └── main.ts # 应用入口 +│ ├── package.json # Node 依赖 +│ ├── vite.config.ts # Vite 配置 +│ └── .env.example # 环境变量示例 +│ +└── README.md # 项目说明 +``` + +## API 接口 + +### 应用相关 + +- `GET /api/apps/search?q={query}` - 搜索应用 +- `GET /api/apps/categories` - 获取所有分类 +- `GET /api/apps/category/{category}` - 按分类获取应用 +- `GET /api/apps/today` - 获取今日上架应用 +- `GET /api/apps/top-downloads` - 热门应用 Top 100 +- `GET /api/apps/top-ratings` - 高分应用 Top 100 +- `GET /api/apps/{app_id}` - 获取应用详情 + +## 设计规范 + +- **圆角**: 统一使用 8px 圆角 +- **配色**: 参考 Apple 设计,以白色、灰色为主 +- **字体**: 使用系统默认字体栈 +- **阴影**: 使用柔和的阴影效果 +- **过渡**: 使用流畅的过渡动画 + +## 开发计划 + +- [x] 基础架构搭建 +- [x] 首页模块 +- [x] 应用列表页 +- [x] 应用详情页 +- [x] 搜索功能 +- [x] 分类浏览 +- [ ] 用户登录 +- [ ] 个人中心完善 +- [ ] 应用收藏 +- [ ] 评论功能 + +## 许可证 + +MIT License diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..131c6e5 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,9 @@ +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=your_password +MYSQL_DATABASE=huawei_market + +API_PREFIX=/api +DEBUG=False +CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..7d7e007 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,40 @@ +# 后端 API 服务 + +基于 FastAPI 的鸿蒙应用展示平台后端服务。 + +## 安装 + +```bash +# 创建虚拟环境 +python -m venv venv +source venv/bin/activate + +# 安装依赖 +pip install -r requirements.txt +``` + +## 配置 + +复制 `.env.example` 为 `.env` 并配置数据库连接: + +```env +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=your_password +MYSQL_DATABASE=huawei_market +``` + +## 运行 + +```bash +python -m app.main +``` + +服务将在 http://localhost:8000 启动 + +## API 文档 + +启动服务后访问: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8021c72 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# Backend Application diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..0154438 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API Routes diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py new file mode 100644 index 0000000..f028413 --- /dev/null +++ b/backend/app/api/apps.py @@ -0,0 +1,331 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_ +from datetime import datetime, timedelta +from typing import Optional +from app.database import get_db +from app.models import AppInfo, AppMetrics, AppRating +from app.schemas import ApiResponse + +router = APIRouter(prefix="/apps", tags=["应用"]) + +@router.get("/search") +async def search_apps( + q: str = Query(..., min_length=1), + page: int = Query(1, ge=1), + page_size: int = Query(20, le=100), + db: AsyncSession = Depends(get_db) +): + """搜索应用""" + subquery = ( + select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at')) + .group_by(AppMetrics.app_id) + .subquery() + ) + + query = ( + select(AppInfo, AppMetrics, AppRating) + .join(AppMetrics, AppInfo.app_id == AppMetrics.app_id) + .outerjoin(AppRating, AppInfo.app_id == AppRating.app_id) + .join(subquery, and_( + AppMetrics.app_id == subquery.c.app_id, + AppMetrics.created_at == subquery.c.max_created_at + )) + .where(or_( + AppInfo.name.like(f"%{q}%"), + AppInfo.pkg_name.like(f"%{q}%"), + AppInfo.developer_name.like(f"%{q}%") + )) + .order_by(AppMetrics.download_count.desc()) + ) + + count_query = select(func.count(AppInfo.app_id)).where(or_( + AppInfo.name.like(f"%{q}%"), + AppInfo.pkg_name.like(f"%{q}%"), + AppInfo.developer_name.like(f"%{q}%") + )) + + total_result = await db.execute(count_query) + total = total_result.scalar() + + offset = (page - 1) * page_size + query = query.offset(offset).limit(page_size) + + result = await db.execute(query) + rows = result.all() + + data = [{ + "app_id": row[0].app_id, + "name": row[0].name, + "pkg_name": row[0].pkg_name, + "developer_name": row[0].developer_name, + "kind_name": row[0].kind_name, + "icon_url": row[0].icon_url, + "brief_desc": row[0].brief_desc, + "download_count": row[1].download_count if len(row) > 1 else 0, + "version": row[1].version if len(row) > 1 else "", + "average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0, + "listed_at": row[0].listed_at.isoformat() + } for row in rows] + + return ApiResponse(success=True, data=data, total=total, limit=page_size) + +@router.get("/category/{category}") +async def get_apps_by_category( + category: str, + page: int = Query(1, ge=1), + page_size: int = Query(20, le=100), + db: AsyncSession = Depends(get_db) +): + """按分类获取应用""" + subquery = ( + select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at')) + .group_by(AppMetrics.app_id) + .subquery() + ) + + query = ( + select(AppInfo, AppMetrics, AppRating) + .join(AppMetrics, AppInfo.app_id == AppMetrics.app_id) + .outerjoin(AppRating, AppInfo.app_id == AppRating.app_id) + .join(subquery, and_( + AppMetrics.app_id == subquery.c.app_id, + AppMetrics.created_at == subquery.c.max_created_at + )) + .where(AppInfo.kind_name == category) + .order_by(AppMetrics.download_count.desc()) + ) + + count_query = select(func.count(AppInfo.app_id)).where(AppInfo.kind_name == category) + total_result = await db.execute(count_query) + total = total_result.scalar() + + offset = (page - 1) * page_size + query = query.offset(offset).limit(page_size) + + result = await db.execute(query) + rows = result.all() + + data = [{ + "app_id": row[0].app_id, + "name": row[0].name, + "pkg_name": row[0].pkg_name, + "developer_name": row[0].developer_name, + "kind_name": row[0].kind_name, + "icon_url": row[0].icon_url, + "brief_desc": row[0].brief_desc, + "download_count": row[1].download_count if len(row) > 1 else 0, + "version": row[1].version if len(row) > 1 else "", + "average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0, + "listed_at": row[0].listed_at.isoformat() + } for row in rows] + + return ApiResponse(success=True, data=data, total=total, limit=page_size) + +@router.get("/categories") +async def get_categories(db: AsyncSession = Depends(get_db)): + """获取所有分类""" + result = await db.execute( + select(AppInfo.kind_name, func.count(AppInfo.app_id).label('count')) + .group_by(AppInfo.kind_name) + .order_by(func.count(AppInfo.app_id).desc()) + ) + rows = result.all() + + data = [{"name": row[0], "count": row[1]} for row in rows] + return ApiResponse(success=True, data=data) + +@router.get("/today") +async def get_today_apps( + page_size: int = Query(20, le=100), + db: AsyncSession = Depends(get_db) +): + """获取今日上架应用""" + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + subquery = ( + select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at')) + .group_by(AppMetrics.app_id) + .subquery() + ) + + query = ( + select(AppInfo, AppMetrics, AppRating) + .join(AppMetrics, AppInfo.app_id == AppMetrics.app_id) + .outerjoin(AppRating, AppInfo.app_id == AppRating.app_id) + .join(subquery, and_( + AppMetrics.app_id == subquery.c.app_id, + AppMetrics.created_at == subquery.c.max_created_at + )) + .where(AppInfo.listed_at >= today) + .order_by(AppInfo.listed_at.desc()) + .limit(page_size) + ) + + result = await db.execute(query) + rows = result.all() + + data = [{ + "app_id": row[0].app_id, + "name": row[0].name, + "pkg_name": row[0].pkg_name, + "developer_name": row[0].developer_name, + "kind_name": row[0].kind_name, + "icon_url": row[0].icon_url, + "brief_desc": row[0].brief_desc, + "download_count": row[1].download_count if len(row) > 1 else 0, + "version": row[1].version if len(row) > 1 else "", + "average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0, + "listed_at": row[0].listed_at.isoformat() + } for row in rows] + + return ApiResponse(success=True, data=data, total=len(data)) + +@router.get("/top-downloads") +async def get_top_downloads( + limit: int = Query(100, le=100), + db: AsyncSession = Depends(get_db) +): + """热门应用Top100""" + subquery = ( + select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at')) + .group_by(AppMetrics.app_id) + .subquery() + ) + + query = ( + select(AppInfo, AppMetrics, AppRating) + .join(AppMetrics, AppInfo.app_id == AppMetrics.app_id) + .outerjoin(AppRating, AppInfo.app_id == AppRating.app_id) + .join(subquery, and_( + AppMetrics.app_id == subquery.c.app_id, + AppMetrics.created_at == subquery.c.max_created_at + )) + .order_by(AppMetrics.download_count.desc()) + .limit(limit) + ) + + result = await db.execute(query) + rows = result.all() + + data = [{ + "app_id": row[0].app_id, + "name": row[0].name, + "pkg_name": row[0].pkg_name, + "developer_name": row[0].developer_name, + "kind_name": row[0].kind_name, + "icon_url": row[0].icon_url, + "brief_desc": row[0].brief_desc, + "download_count": row[1].download_count if len(row) > 1 else 0, + "version": row[1].version if len(row) > 1 else "", + "average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0 + } for row in rows] + + return ApiResponse(success=True, data=data, total=len(data)) + +@router.get("/top-ratings") +async def get_top_ratings( + limit: int = Query(100, le=100), + db: AsyncSession = Depends(get_db) +): + """评分Top100""" + subquery_metric = ( + select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at')) + .group_by(AppMetrics.app_id) + .subquery() + ) + + subquery_rating = ( + select(AppRating.app_id, func.max(AppRating.created_at).label('max_created_at')) + .group_by(AppRating.app_id) + .subquery() + ) + + query = ( + select(AppInfo, AppMetrics, AppRating) + .join(AppMetrics, AppInfo.app_id == AppMetrics.app_id) + .join(AppRating, AppInfo.app_id == AppRating.app_id) + .join(subquery_metric, and_( + AppMetrics.app_id == subquery_metric.c.app_id, + AppMetrics.created_at == subquery_metric.c.max_created_at + )) + .join(subquery_rating, and_( + AppRating.app_id == subquery_rating.c.app_id, + AppRating.created_at == subquery_rating.c.max_created_at + )) + .where(AppRating.total_rating_count >= 100) + .order_by(AppRating.average_rating.desc()) + .limit(limit) + ) + + result = await db.execute(query) + rows = result.all() + + data = [{ + "app_id": row[0].app_id, + "name": row[0].name, + "pkg_name": row[0].pkg_name, + "developer_name": row[0].developer_name, + "kind_name": row[0].kind_name, + "icon_url": row[0].icon_url, + "brief_desc": row[0].brief_desc, + "download_count": row[1].download_count if len(row) > 1 else 0, + "version": row[1].version if len(row) > 1 else "", + "average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0, + "total_rating_count": row[2].total_rating_count if len(row) > 2 and row[2] else 0 + } for row in rows] + + return ApiResponse(success=True, data=data, total=len(data)) + +@router.get("/{app_id}") +async def get_app_detail(app_id: str, db: AsyncSession = Depends(get_db)): + """获取应用详情""" + subquery = ( + select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at')) + .where(AppMetrics.app_id == app_id) + .group_by(AppMetrics.app_id) + .subquery() + ) + + query = ( + select(AppInfo, AppMetrics, AppRating) + .join(AppMetrics, AppInfo.app_id == AppMetrics.app_id) + .outerjoin(AppRating, AppInfo.app_id == AppRating.app_id) + .join(subquery, and_( + AppMetrics.app_id == subquery.c.app_id, + AppMetrics.created_at == subquery.c.max_created_at + )) + .where(AppInfo.app_id == app_id) + ) + + result = await db.execute(query) + row = result.first() + + if not row: + raise HTTPException(status_code=404, detail="应用不存在") + + data = { + "app_id": row[0].app_id, + "name": row[0].name, + "pkg_name": row[0].pkg_name, + "developer_name": row[0].developer_name, + "kind_name": row[0].kind_name, + "icon_url": row[0].icon_url, + "brief_desc": row[0].brief_desc, + "description": row[0].description, + "privacy_url": row[0].privacy_url, + "is_pay": row[0].is_pay, + "listed_at": row[0].listed_at.isoformat(), + "download_count": row[1].download_count if len(row) > 1 else 0, + "version": row[1].version if len(row) > 1 else "", + "size_bytes": row[1].size_bytes if len(row) > 1 else 0, + "average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0, + "total_rating_count": row[2].total_rating_count if len(row) > 2 and row[2] else 0, + "star_1_count": row[2].star_1_count if len(row) > 2 and row[2] else 0, + "star_2_count": row[2].star_2_count if len(row) > 2 and row[2] else 0, + "star_3_count": row[2].star_3_count if len(row) > 2 and row[2] else 0, + "star_4_count": row[2].star_4_count if len(row) > 2 and row[2] else 0, + "star_5_count": row[2].star_5_count if len(row) > 2 and row[2] else 0 + } + + return ApiResponse(success=True, data=data) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..77ce610 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,25 @@ +from pydantic_settings import BaseSettings +from typing import List + +class Settings(BaseSettings): + MYSQL_HOST: str = "localhost" + MYSQL_PORT: int = 3306 + MYSQL_USER: str = "root" + MYSQL_PASSWORD: str = "password" + MYSQL_DATABASE: str = "huawei_market" + + API_PREFIX: str = "/api" + API_TITLE: str = "鸿蒙应用展示平台API" + API_VERSION: str = "1.0.0" + + DEBUG: bool = False + CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"] + + @property + def database_url(self) -> str: + return f"mysql+aiomysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}" + + class Config: + env_file = ".env" + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..9d44369 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,27 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.config import settings + +engine = create_async_engine( + settings.database_url, + echo=settings.DEBUG, + pool_size=10, + max_overflow=20, + pool_pre_ping=True +) + +AsyncSessionLocal = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False +) + +Base = declarative_base() + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..0ffcfd1 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,31 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.config import settings +from app.api import apps + +app = FastAPI( + title=settings.API_TITLE, + version=settings.API_VERSION +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(apps.router, prefix=settings.API_PREFIX) + +@app.get("/") +async def root(): + return {"message": "鸿蒙应用展示平台API", "version": settings.API_VERSION} + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=settings.DEBUG) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..d755d0d --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,5 @@ +from app.models.app_info import AppInfo +from app.models.app_metrics import AppMetrics +from app.models.app_rating import AppRating + +__all__ = ["AppInfo", "AppMetrics", "AppRating"] diff --git a/backend/app/models/app_info.py b/backend/app/models/app_info.py new file mode 100644 index 0000000..9c50f64 --- /dev/null +++ b/backend/app/models/app_info.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, String, Integer, Text, DateTime, Boolean, JSON +from sqlalchemy.sql import func +from app.database import Base + +class AppInfo(Base): + __tablename__ = "app_info" + + app_id = Column(String(50), primary_key=True) + name = Column(String(255), nullable=False, index=True) + pkg_name = Column(String(255), nullable=False, unique=True, index=True) + developer_name = Column(String(255), nullable=False, index=True) + kind_name = Column(String(100), nullable=False, index=True) + icon_url = Column(Text, nullable=False) + brief_desc = Column(Text, nullable=False) + description = Column(Text, nullable=False) + privacy_url = Column(Text, nullable=False) + is_pay = Column(Boolean, default=False) + listed_at = Column(DateTime, nullable=False) + created_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/app_metrics.py b/backend/app/models/app_metrics.py new file mode 100644 index 0000000..a7ace3f --- /dev/null +++ b/backend/app/models/app_metrics.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, String, Integer, BigInteger, DateTime, DECIMAL, ForeignKey +from sqlalchemy.sql import func +from app.database import Base + +class AppMetrics(Base): + __tablename__ = "app_metrics" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + app_id = Column(String(50), ForeignKey("app_info.app_id", ondelete="CASCADE"), nullable=False, index=True) + pkg_name = Column(String(255), ForeignKey("app_info.pkg_name", ondelete="CASCADE"), nullable=False, index=True) + version = Column(String(50), nullable=False) + size_bytes = Column(BigInteger, nullable=False) + download_count = Column(BigInteger, nullable=False, index=True) + release_date = Column(BigInteger, nullable=False) + created_at = Column(DateTime, nullable=False, server_default=func.now(), index=True) diff --git a/backend/app/models/app_rating.py b/backend/app/models/app_rating.py new file mode 100644 index 0000000..58001ce --- /dev/null +++ b/backend/app/models/app_rating.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, String, Integer, BigInteger, DateTime, DECIMAL, ForeignKey +from sqlalchemy.sql import func +from app.database import Base + +class AppRating(Base): + __tablename__ = "app_rating" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + app_id = Column(String(50), ForeignKey("app_info.app_id", ondelete="CASCADE"), nullable=False, index=True) + pkg_name = Column(String(255), ForeignKey("app_info.pkg_name", ondelete="CASCADE"), nullable=False, index=True) + average_rating = Column(DECIMAL(3, 2), nullable=False, index=True) + star_1_count = Column(Integer, nullable=False, default=0) + star_2_count = Column(Integer, nullable=False, default=0) + star_3_count = Column(Integer, nullable=False, default=0) + star_4_count = Column(Integer, nullable=False, default=0) + star_5_count = Column(Integer, nullable=False, default=0) + total_rating_count = Column(Integer, nullable=False, default=0) + created_at = Column(DateTime, nullable=False, server_default=func.now(), index=True) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..344832f --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,3 @@ +from app.schemas.response import ApiResponse + +__all__ = ["ApiResponse"] diff --git a/backend/app/schemas/response.py b/backend/app/schemas/response.py new file mode 100644 index 0000000..f92fc99 --- /dev/null +++ b/backend/app/schemas/response.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel +from typing import Any, Optional +from datetime import datetime + +class ApiResponse(BaseModel): + success: bool + data: Any + total: Optional[int] = None + limit: Optional[int] = None + timestamp: str = datetime.now().isoformat() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c255bae --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +aiomysql==0.2.0 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-dotenv==1.0.0 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..e3b0e79 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:8000/api diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..497ade8 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,31 @@ +# 前端应用 + +基于 Vue 3 + TypeScript 的鸿蒙应用展示平台前端。 + +## 安装 + +```bash +npm install +``` + +## 开发 + +```bash +npm run dev +``` + +应用将在 http://localhost:5173 启动 + +## 构建 + +```bash +npm run build +``` + +构建产物将输出到 `dist` 目录 + +## 预览 + +```bash +npm run preview +``` diff --git a/frontend/README_IMAGES.md b/frontend/README_IMAGES.md new file mode 100644 index 0000000..08ccdd8 --- /dev/null +++ b/frontend/README_IMAGES.md @@ -0,0 +1,34 @@ +# 图片资源说明 + +请将以下图片放置在 `frontend/public/` 目录下: + +## 必需图片 + +1. **new.png** - 今日上架背景图 + - 尺寸:370x370px + - 格式:PNG + - 说明:用于首页左上角的今日上架卡片背景 + +2. **harmonyos.png** - 鸿蒙系统背景图 + - 尺寸:370x370px + - 格式:PNG + - 说明:用于首页右上角的鸿蒙系统卡片背景 + +3. **coming.png** - 即将上线背景图 + - 尺寸:370x370px + - 格式:PNG + - 说明:用于首页左下角的即将上线卡片背景 + +## 图片要求 + +- 所有图片应为正方形,推荐尺寸 370x370px +- 使用 PNG 格式以支持透明背景 +- 文件大小建议控制在 200KB 以内 +- 图片应清晰,适合 Retina 显示屏 + +## 临时方案 + +如果暂时没有图片,可以使用纯色背景: +- 今日上架:渐变蓝色 (#007AFF → #5856D6) +- 鸿蒙系统:渐变紫色 (#667eea → #764ba2) +- 即将上线:渐变橙色 (#FF9500 → #FF3B30) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..efa36d1 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 鸿蒙应用展示平台 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..913c966 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1699 @@ +{ + "name": "harmonyos-app-gallery", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "harmonyos-app-gallery", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.5", + "pinia": "^2.1.7", + "vue": "^3.4.0", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.3", + "vite": "^5.0.0", + "vue-tsc": "^1.8.27" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz", + "integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "@vue/shared": "3.5.22", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz", + "integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz", + "integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "@vue/compiler-core": "3.5.22", + "@vue/compiler-dom": "3.5.22", + "@vue/compiler-ssr": "3.5.22", + "@vue/shared": "3.5.22", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.19", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz", + "integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", + "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", + "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", + "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.22", + "@vue/runtime-core": "3.5.22", + "@vue/shared": "3.5.22", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz", + "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.22", + "@vue/shared": "3.5.22" + }, + "peerDependencies": { + "vue": "3.5.22" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz", + "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", + "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.22", + "@vue/compiler-sfc": "3.5.22", + "@vue/runtime-dom": "3.5.22", + "@vue/server-renderer": "3.5.22", + "@vue/shared": "3.5.22" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz", + "integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d20dae2 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "harmonyos-app-gallery", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.2.5", + "pinia": "^2.1.7", + "axios": "^1.6.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.3", + "vite": "^5.0.0", + "vue-tsc": "^1.8.27" + } +} diff --git a/frontend/public/coming.png b/frontend/public/coming.png new file mode 100755 index 0000000..7a63728 Binary files /dev/null and b/frontend/public/coming.png differ diff --git a/frontend/public/new.png b/frontend/public/new.png new file mode 100755 index 0000000..420abf7 Binary files /dev/null and b/frontend/public/new.png differ diff --git a/frontend/public/pc-harmonyos5.png b/frontend/public/pc-harmonyos5.png new file mode 100755 index 0000000..6558bf7 Binary files /dev/null and b/frontend/public/pc-harmonyos5.png differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..27471d6 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..aecd053 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,79 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || '/api', + timeout: 30000 +}) + +api.interceptors.response.use( + response => response.data, + error => { + console.error('API Error:', error) + return Promise.reject(error) + } +) + +export interface ApiResponse { + success: boolean + data: T + total?: number + limit?: number + timestamp: string +} + +export interface AppItem { + app_id: string + name: string + pkg_name: string + developer_name: string + kind_name: string + icon_url: string + brief_desc: string + download_count: number + version: string + average_rating: number + total_rating_count?: number + listed_at: string +} + +export interface AppDetail extends AppItem { + description: string + privacy_url: string + is_pay: boolean + size_bytes: number + star_1_count: number + star_2_count: number + star_3_count: number + star_4_count: number + star_5_count: number +} + +export interface Category { + name: string + count: number +} + +export const appsApi = { + search: (q: string, page = 1, pageSize = 20) => + api.get>('/apps/search', { params: { q, page, page_size: pageSize } }), + + getCategories: () => + api.get>('/apps/categories'), + + getByCategory: (category: string, page = 1, pageSize = 20) => + api.get>(`/apps/category/${category}`, { params: { page, page_size: pageSize } }), + + getTodayApps: (pageSize = 20) => + api.get>('/apps/today', { params: { page_size: pageSize } }), + + getTopDownloads: (limit = 100) => + api.get>('/apps/top-downloads', { params: { limit } }), + + getTopRatings: (limit = 100) => + api.get>('/apps/top-ratings', { params: { limit } }), + + getDetail: (appId: string) => + api.get>(`/apps/${appId}`) +} + +export default api diff --git a/frontend/src/assets/styles/main.css b/frontend/src/assets/styles/main.css new file mode 100644 index 0000000..5669557 --- /dev/null +++ b/frontend/src/assets/styles/main.css @@ -0,0 +1,36 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: #F5F5F7; + color: #1d1d1f; +} + +#app { + min-height: 100vh; +} + +:root { + --border-radius: 8px; + --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + --card-hover-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +@media (max-width: 768px) { + .container { + padding: 0 16px; + } +} diff --git a/frontend/src/components/AppCard.vue b/frontend/src/components/AppCard.vue new file mode 100644 index 0000000..2f94d27 --- /dev/null +++ b/frontend/src/components/AppCard.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..f266ec6 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './assets/styles/main.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..3042a3d --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,33 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Home from '@/views/Home.vue' +import Apps from '@/views/Apps.vue' +import AppDetail from '@/views/AppDetail.vue' +import Profile from '@/views/Profile.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'Home', + component: Home + }, + { + path: '/apps', + name: 'Apps', + component: Apps + }, + { + path: '/app/:id', + name: 'AppDetail', + component: AppDetail + }, + { + path: '/profile', + name: 'Profile', + component: Profile + } + ] +}) + +export default router diff --git a/frontend/src/views/AppDetail.vue b/frontend/src/views/AppDetail.vue new file mode 100644 index 0000000..433cb98 --- /dev/null +++ b/frontend/src/views/AppDetail.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/frontend/src/views/Apps.vue b/frontend/src/views/Apps.vue new file mode 100644 index 0000000..24bc1f2 --- /dev/null +++ b/frontend/src/views/Apps.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue new file mode 100644 index 0000000..37989ef --- /dev/null +++ b/frontend/src/views/Home.vue @@ -0,0 +1,429 @@ + + + + + diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue new file mode 100644 index 0000000..d19500b --- /dev/null +++ b/frontend/src/views/Profile.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..4e5ea1b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..3903d24 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true + } + } + } +}) diff --git a/templates/404.html b/templates/404.html new file mode 100755 index 0000000..e1c2c37 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,122 @@ + + + + + 404 - 页面未找到 + + + + + + +
+ +
404
+

页面未找到

+

抱歉,您访问的页面不存在或已被移除。

+ + + 返回首页 + +
+ + \ No newline at end of file diff --git a/templates/admin.html b/templates/admin.html new file mode 100755 index 0000000..cfbd833 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,202 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+ +
+
+
+
+ +

添加新应用

+
+
+
+
+ + +
+ {% if is_superadmin() %} +
+ +
+ + +
+
+ + +
+
+ {% else %} +
+ + +
+ {% endif %} +
+ + +
+
+ + +
+ +
+
+
+ + +
+
+
+ +

添加新分类

+
+
+
+ + +
+ +
+ + +
+ {% for category in categories %} + {% if is_superadmin() %} +
+ {{ category.name }} + + + + +
+ {% else %} +
+ {{ category.name }} +
+ {% endif %} + {% endfor %} +
+
+
+ + +
+
+
+ +

应用管理 {% if search %}(搜索结果){% else %}(共{{ apps|length }}个){% endif %}

+
+ +
+ + + {% if is_superadmin() %} +
+ +
+ {% endif %} + +
+ + + + + + + + + + + + + {% for app in apps %} + + + + + + + + + {% endfor %} + +
图标名称分类添加时间操作
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+
+
+ +
+ + + +
+
+
+ {% for category in categories %} + {% if category.id == app.category_id %} + {{ category.name }} + {% endif %} + {% endfor %} + {{ app.created_at }} +
+ {% if is_superadmin() %} + + + + {% endif %} +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin_add.html b/templates/admin_add.html new file mode 100755 index 0000000..d129f9e --- /dev/null +++ b/templates/admin_add.html @@ -0,0 +1,581 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+ +
+
+
+ +

添加单个应用

+
+
+
+
+ + +
+
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + + +
+ +
+
+ + +
+ +
+
+ + +
+
+
+ +

批量导入应用

+
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+
+ + + +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_apps.html b/templates/admin_apps.html new file mode 100755 index 0000000..fa513cb --- /dev/null +++ b/templates/admin_apps.html @@ -0,0 +1,966 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

应用管理 {% if search %}(搜索结果){% else %}(共{{ total_count }}个){% endif %}

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + +
图标名称分类添加时间操作
+ + + +
+ +
+
+
+
+
+ + + +
+
+
+

筛选结果

+ × +
+
+ +
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_batch_import.html b/templates/admin_batch_import.html new file mode 100755 index 0000000..ccdd81b --- /dev/null +++ b/templates/admin_batch_import.html @@ -0,0 +1,177 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

批量导入应用

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+ +

导入列表

+
+
+
+ + + + + + + + + + + + +
图标名称分类操作
+
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_categories.html b/templates/admin_categories.html new file mode 100755 index 0000000..8e279a5 --- /dev/null +++ b/templates/admin_categories.html @@ -0,0 +1,512 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

分类管理

+
+ +
+ +
+
+
+
分类名称
+
应用数量
+
操作
+
+
+ {% for category in categories %} +
+
+ +
+
{{ category.name }}
+
{{ category.app_count }}个应用
+
+ + +
+
+ {% endfor %} +
+
+
+
+
+ + + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_coming.html b/templates/admin_coming.html new file mode 100755 index 0000000..9811b25 --- /dev/null +++ b/templates/admin_coming.html @@ -0,0 +1,680 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

即将上线应用管理

+
+
+ +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + + + +
+ +
+
+
+
图标
+
名称
+
添加时间
+
操作
+
+
+ {% for app in coming_apps %} +
+
+ +
+
+
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+ +
+
+ +
+
+
{{ app.name }}
+
{{ app.created_at }}
+
+ +
+
+ {% endfor %} +
+
+
+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html new file mode 100755 index 0000000..5fed718 --- /dev/null +++ b/templates/admin_dashboard.html @@ -0,0 +1,618 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+

网站概览

+
+ + 刷新数据 +
+
+ + +
+
+
+ +
+
+
总用户数
+
{{ stats.total_users }}
+
+ 今日新增 {{ stats.today_users }} +
+ 今日邀请 {{ stats.today_inviters or 0 }} / 被邀请 {{ stats.today_invitees or 0 }} +
+
+
+ +
+
+ +
+
+
心愿单总数
+
{{ stats.total_wishlists }}
+
今日新增 {{ stats.today_wishlists }}
+
+
+ +
+
+ +
+
+
通知状态
+
{{ stats.notified_wishlists }}
+
+ 已通知 {{ stats.notified_wishlists }} / 未通知 {{ stats.unnotified_wishlists }} +
+ 今日通知 {{ stats.today_notified }} +
+
+
+ +
+
+ +
+
+
应用总数
+
{{ stats.total_apps }}
+
+ 手机端 {{ stats.mobile_apps }} / 平板端 {{ stats.tablet_apps }} +
+ 今日新增 {{ stats.today_apps }} +
+
+
+ +
+
+ +
+
+
通知成功率
+ {% set success_rate = (stats.notified_wishlists / stats.total_wishlists * 100)|round(2) if stats.total_wishlists > 0 else 0 %} +
{{ success_rate }}%
+
+ 总通知数 {{ stats.total_wishlists }} +
+ 成功 {{ stats.notified_wishlists }} +
+
+
+ +
+
+ +
+
+
活跃用户
+
{{ stats.user_activity.active_users }}
+
+ 活跃率 {{ stats.user_activity.active_rate }}% +
+ 总用户 {{ stats.total_users }} +
+
+
+
+ + +
+
+

最近7天注册趋势

+ +
+ +
+

热门应用 TOP10

+ +
+ +
+

应用分类分布

+ +
+ +
+

最近7天心愿单趋势

+ +
+ +
+

最近30天应用增长趋势

+ +
+ + +
+

用户活跃度分析

+ +
+ + +
+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_nav.html b/templates/admin_nav.html new file mode 100755 index 0000000..163d643 --- /dev/null +++ b/templates/admin_nav.html @@ -0,0 +1,334 @@ +
+ +

NEXT Store

+
+ + + + + + + + \ No newline at end of file diff --git a/templates/admin_permissions.html b/templates/admin_permissions.html new file mode 100755 index 0000000..33b2a85 --- /dev/null +++ b/templates/admin_permissions.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

权限管理

+
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_users.html b/templates/admin_users.html new file mode 100755 index 0000000..bd277ec --- /dev/null +++ b/templates/admin_users.html @@ -0,0 +1,680 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

用户管理

+
+
+
+ + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + + + {% endfor %} + +
头像昵称邮箱华为ID邀请人数被邀请者注册时间最近登录登录次数操作
+ {{ user.name }} + {{ user.name }}{{ user.email or '未设置' }}{{ user.huawei_id }} +
+ {{ user.invite_count }} + {% if user.invite_count > 0 %} + + {% endif %} +
+
+ {% if user.inviter_name %} + + + {{ user.inviter_name }} + + {% else %} + - + {% endif %} + +
+ 注册时间 + {{ user.created_at }} +
+
+
+ 最近登录 + {{ user.last_login or '未登录' }} +
+
{{ user.login_count }} +
+ +
+
+
+
+
+
+ + + + + +
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_wiki.html b/templates/admin_wiki.html new file mode 100755 index 0000000..4708102 --- /dev/null +++ b/templates/admin_wiki.html @@ -0,0 +1,965 @@ +{% extends "base.html" %} + +{% block title %}Wiki 管理{% endblock %} + +{% block head %} + + + + + + + + +{% endblock %} + +{% block content %} +{% include 'admin_nav.html' %} +
+

添加 Wiki 条目

+ +
+

添加新条目

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+ +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+
+ +
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_wiki_categories.html b/templates/admin_wiki_categories.html new file mode 100755 index 0000000..d265efb --- /dev/null +++ b/templates/admin_wiki_categories.html @@ -0,0 +1,541 @@ +{% extends "base.html" %} + +{% block title %}Wiki 分类管理{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +{% include 'admin_nav.html' %} +
+

Wiki 分类管理

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ + + + + + +
+
+ + + + + + + + + + + + + + + {% for category in categories %} + + + + + + + + + + {% endfor %} + +
ID一级分类二级分类三级分类描述排序操作
{{ category.id }} + + {{ category.first_level }} + + + {{ category.second_level }} + + + {{ category.third_level }} + {{ category.description or '' }}{{ category.sort_order }} + + +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_wiki_comments.html b/templates/admin_wiki_comments.html new file mode 100755 index 0000000..cf42ae7 --- /dev/null +++ b/templates/admin_wiki_comments.html @@ -0,0 +1,577 @@ +{% extends "base.html" %} + + +{% block content %} +{% include 'admin_nav.html' %} +
+
+

Wiki 评论审核

+
+
+ + +
+
+
+ + + +
+ + + + + + + + + + + + + + + {% for comment in comments %} + + + + + + + + + + + {% endfor %} + +
IDWiki 标题用户评论类型内容状态创建时间操作
{{ comment.id }} +
+ {{ comment.wiki_title }} +
+
+
+ {% if comment.user_avatar %} +
+ {{ comment.user_name }} +
+ {% endif %} + {{ comment.user_name }} +
+
+ + {% if comment.comment_type == 'feature_request' %}功能建议 + {% elif comment.comment_type == 'bug_report' %}问题报告 + {% else %}新增功能{% endif %} + + +
+
+ {{ comment.content }} +
+ +
+
+ {% if comment.status == 'pending' %} + 待审核 + {% elif comment.status == 'approved' %} + 已通过 + {% else %} + 已拒绝 + {% endif %} + {{ comment.created_at }} +
+ {% if comment.status == 'pending' %} + + + {% endif %} + +
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_wiki_edit.html b/templates/admin_wiki_edit.html new file mode 100755 index 0000000..06a625b --- /dev/null +++ b/templates/admin_wiki_edit.html @@ -0,0 +1,193 @@ +{% extends "base.html" %} + +{% block title %}编辑 Wiki 条目{% endblock %} + +{% block head %} + + + + + + + + +{% endblock %} + +{% block content %} +{% include 'admin_nav.html' %} +
+

编辑 Wiki 条目

+ +
+

编辑条目

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+
+ +
+ +

点击或拖拽图片到此处上传

+ 支持多张图片上传 +
+
+
+ {% for image in entry.images %} +
+ 预览图 + × +
+ {% endfor %} +
+
+
+ +
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_wiki_list.html b/templates/admin_wiki_list.html new file mode 100755 index 0000000..4a081a2 --- /dev/null +++ b/templates/admin_wiki_list.html @@ -0,0 +1,723 @@ +{% extends "base.html" %} + +{% block title %}Wiki 条目管理{% endblock %} + +{% block head %} + + + + + + + + + +{% endblock %} + +{% block content %} +{% include 'admin_nav.html' %} +
+

Wiki 条目列表 ({{ entries|length }})

+ +
+ +
+ +
+ {% for entry in entries %} +
+ {% if entry.image_path %} +
+ {{ entry.title }} +
+ {% endif %} + +
+ + +
+
+ {% endfor %} +
+
+ + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_wishlist.html b/templates/admin_wishlist.html new file mode 100755 index 0000000..41ebd32 --- /dev/null +++ b/templates/admin_wishlist.html @@ -0,0 +1,694 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+ +
+ + +
+ + +
+
+
+ +

未通知应用

+
+
+ + +
+
+ +
+ + + + + + + + + + + + {% for item in wishlist_items %} + {% if not item.notified %} + + + + + + + + {% endif %} + {% endfor %} + +
应用名称关注人数最早添加时间最近添加时间操作
{{ item.app_name }}{{ item.count }}{{ item.first_added }}{{ item.last_added }} +
+ + + +
+
+
+
+ + + +
+
+ + + + + +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/all_apps.html b/templates/all_apps.html new file mode 100755 index 0000000..a988e7b --- /dev/null +++ b/templates/all_apps.html @@ -0,0 +1,342 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ + + +
+

全部应用 ({{ apps|length }}个)

+
+
+ +
+ {% for app in apps %} +
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+
+

{{ app.name }}

+
+
+ {% endfor %} +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/app_detail.html b/templates/app_detail.html new file mode 100755 index 0000000..c2788cf --- /dev/null +++ b/templates/app_detail.html @@ -0,0 +1,492 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ +
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+
+
+
+

{{ app.name }}

+ {{ category.name }} +
+
+ {% if app.visit_url %} + + + 访问 + + {% endif %} + {% if app.download_url %} + + + 下载 + + {% endif %} +
+
+
+
+
+ +
+
+

应用描述

+
{{ app.description or '暂无描述' }}
+
+ +
+

应用信息

+
+
+ + + {% if not app.platform %} + + 手机端 + + {% else %} + {% if 'mobile' in app.platform.split(',') %} + + 手机端 + + {% endif %} + {% if 'tablet' in app.platform.split(',') %} + + 平板端 + + {% endif %} + {% endif %} + +
+
+ + {{ app.version or '暂无' }} +
+
+ + + {% if app.update_date %} + {% if app.update_date is string %} + {{ app.update_date[:10] }} + {% else %} + {{ app.update_date.strftime('%Y-%m-%d') }} + {% endif %} + {% elif app.created_at %} + {% if app.created_at is string %} + {{ app.created_at[:10] }} + {% else %} + {{ app.created_at.strftime('%Y-%m-%d') }} + {% endif %} + {% else %} + 暂无 + {% endif %} + +
+
+ + {{ app.developer or '暂无' }} +
+
+
+ + +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/app_edit.html b/templates/app_edit.html new file mode 100755 index 0000000..e4e9d50 --- /dev/null +++ b/templates/app_edit.html @@ -0,0 +1,423 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

应用详情 (Top50热门应用)

+
+
+ + +
+
+
+ + +
+
+ +
+ {% for app in apps %} +
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+
+
+

{{ app.name }}

+
+ {{ app.category_name }} +
+
+ 搜索 {{ app.search_count }} 次 +
+
+ {% endfor %} +
+
+ + {% if selected_app %} +
+
+
+ {% if 'http' in selected_app.icon_path %} + {{ selected_app.name }} + {% else %} + {{ selected_app.name }} + {% endif %} +
+
+
+ + +
+ +
+ + +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ {% endif %} +
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/auto_import.html b/templates/auto_import.html new file mode 100755 index 0000000..fdbe87f --- /dev/null +++ b/templates/auto_import.html @@ -0,0 +1,1026 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

自动导入应用

+
+
+ +
+
+ + +
+
+ +
+ + + +
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + + +
+ + + + + +
+
+
+ +

最近导入

+
+
+
+ +
+
+
+
+ + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100755 index 0000000..6ee4715 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,1494 @@ + + + + + + + + NEXT Store + + + + + + + + + + + + {% block head %} + + + + + + + + + + + + + + + + + + + {% endblock %} + + + + + + + + + + + + + {% if settings and settings.grayscale_enabled == '1' %} + + {% endif %} + + + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} + + {# 只在首页显示特别鸣谢 #} + {% if request.endpoint == 'index' %} +
+
+

特别鸣谢

+
+
+
+
+ +
+
+
+
+ + + {% endif %} + + {% block footer %}{% endblock %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/category.html b/templates/category.html new file mode 100755 index 0000000..c37b4bf --- /dev/null +++ b/templates/category.html @@ -0,0 +1,278 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ + + +
+

{{ category.name }} ({{ apps|length }}个)

+ {% if platform == 'tablet' %} + + 平板应用 + + {% endif %} +
+
+ +
+ {% for app in apps %} +
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+
+

{{ app.name }}

+
+
+ {% endfor %} +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/coming.html b/templates/coming.html new file mode 100755 index 0000000..c01d886 --- /dev/null +++ b/templates/coming.html @@ -0,0 +1,475 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ +
+ +
+ + + +

即将上线

+
+ +
+ {% if coming_apps %} + {% for app in coming_apps %} +
+
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+
+
+

{{ app.name }}

+ +
+
+ 即将上线 +
+
+
+
+ {% endfor %} + {% else %} +
暂无即将上线应用
+ {% endif %} +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/cookie_policy.html b/templates/cookie_policy.html new file mode 100755 index 0000000..816d9c4 --- /dev/null +++ b/templates/cookie_policy.html @@ -0,0 +1,187 @@ +{% extends "policy_base.html" %} + +{% block policy_header %}Cookie 政策{% endblock %} +{% block policy_icon %}fas fa-cookie-bite{% endblock %} +{% block policy_title %}Cookie 政策{% endblock %} + +{% block policy_content %} +
+

最近更新:2025年1月15日

+
+ +
+

我们如何使用 Cookie?

+

我们启用 Cookie 的目的在于改善用户体验,具体包括:

+
    +
  • 存储偏好设置:保存您的主题模式(明亮/暗黑)等浏览偏好
  • +
  • 提供基本功能:确保网站的核心功能正常运行,如应用展示和搜索
  • +
  • 统计分析:收集匿名的使用数据,如访问次数和浏览习惯,帮助我们改进网站
  • +
  • 性能优化:记录您的浏览偏好,提供更流畅的访问体验
  • +
  • 保护安全:帮助识别异常访问行为,保护网站安全
  • +
+
+ +
+

Cookie 类型

+

必要的 Cookie

+

这些 Cookie 对于网站的正常运行是必需的,不能在我们的系统中关闭。它们通常用于维持网站的基本功能,如页面展示和主题切换等。

+ +

分析 Cookie

+

这些 Cookie 帮助我们了解访问者如何使用网站,收集匿名统计数据。这些信息帮助我们:

+
    +
  • 了解访问者如何到达我们的网站
  • +
  • 监控网站性能和响应时间
  • +
  • 了解最受欢迎的应用和功能
  • +
  • 发现并解决可能的问题
  • +
+
+ +
+

其他追踪技术

+

网站信标/像素标签

+

除 Cookie 外,我们还可能在网站上使用网站信标或像素标签。这些通常是嵌入到网站或电子邮件中的电子图像,可用于:

+
    +
  • 在您查看网页或电子邮件时识别您的设备
  • +
  • 分析服务使用情况(与 Cookie 配合使用)
  • +
  • 提供更符合您需求的内容
  • +
  • 了解电子邮件是否被打开
  • +
+ +

本地存储

+

我们可能在某些服务中使用其他本地存储技术,例如:

+
    +
  • 本地共享对象(Flash Cookie)
  • +
  • HTML5 本地存储
  • +
+

这些技术与 Cookie 类似,但可能使用不同的存储机制。

+
+ +
+

什么是 Cookie?

+

Cookie 是一种网络服务器存储在计算机或移动设备上的小型文本文件。它们被广泛用于使网站能够更高效地运行,并为您提供更好的用户体验。Cookie 可能来自我们的网站(第一方 Cookie)或来自其他网站(第三方 Cookie)。

+ +

Cookie 的类型

+
    +
  • 会话 Cookie:临时性的,在您关闭浏览器后会被删除
  • +
  • 持久性 Cookie:保存在您的设备上,直到过期或被您删除
  • +
  • 必要性 Cookie:网站基本功能所必需的
  • +
  • 功能性 Cookie:用于记住您的偏好设置
  • +
  • 分析性 Cookie:用于改进网站性能和用户体验
  • +
+
+ +
+

如何管理 Cookie?

+

您可以通过浏览器设置来管理 Cookie。常见的控制方式包括:

+
    +
  • 查看已存储的 Cookie
  • +
  • 删除特定的 Cookie
  • +
  • 阻止网站设置 Cookie
  • +
  • 退出特定类型的 Cookie
  • +
+ +

主流浏览器的 Cookie 设置方法:

+
    +
  • Chrome:设置 → 隐私设置和安全性 → Cookie 和其他网站数据
  • +
  • Firefox:选项 → 隐私与安全 → Cookie 和网站数据
  • +
  • Safari:偏好设置 → 隐私 → Cookie 和网站数据
  • +
  • Edge:设置 → Cookie 和网站权限
  • +
+ +
+ +
+

Cookie 的安全性

+

我们采取以下措施来确保 Cookie 的安全使用:

+
    +
  • 仅收集必要的信息,不包含个人敏感数据
  • +
  • 采用加密技术保护 Cookie 数据
  • +
  • 定期审查和更新 Cookie 政策
  • +
  • 提供清晰的 Cookie 管理选项
  • +
  • 遵守相关的数据保护法规
  • +
+ +

第三方 Cookie

+

我们的网站可能使用第三方服务,这些服务可能会设置它们自己的 Cookie。这些第三方 Cookie 主要用于:

+
    +
  • 网站访问统计和分析
  • +
  • 改善用户体验
  • +
  • 社交媒体功能
  • +
+

我们无法直接控制第三方 Cookie,建议您查看这些第三方的隐私政策了解更多信息。

+
+ +
+

Do Not Track(请勿追踪)

+

很多网络浏览器均设有 Do Not Track 功能,该功能可向网站发布 Do Not Track 请求。目前,主要互联网标准组织尚未设立相关政策来规定网站应如何应对此类请求。

+

我们目前没有根据"请勿跟踪"设置改变数据收集和使用方式,但我们保留在今后修改数据处理方式的权利,如有变更,我们会及时更新本政策。

+
+ +
+

Cookie 使用声明

+

NEXT Store 使用 Cookie 和类似技术的目的是提供更安全、更个性化的用户体验。我们致力于通过先进的技术手段,在保护用户隐私的同时,提供更优质的服务。

+ +

Cookie 管理原则

+
    +
  • 最小必要:仅收集必要信息
  • +
  • 用户选择:提供灵活的 Cookie 控制
  • +
  • 安全加密:保护 Cookie 数据安全
  • +
  • 透明公开:清晰说明 Cookie 使用方式
  • +
+
+ +
+

Cookie 安全与隐私保护

+

我们采取以下措施保护通过 Cookie 收集的信息:

+
    +
  • 使用行业标准加密技术
  • +
  • 定期安全审计
  • +
  • 限制 Cookie 数据访问权限
  • +
  • 及时更新安全策略
  • +
+
+ +
+

更新和联系

+

我们可能会不时更新此 Cookie 政策以反映我们的实践变化和法律要求。任何更改都将在此页面上发布,重大更改时我们会通过适当方式通知您。

+

如果您对我们的 Cookie 使用有任何疑问,请通过网站提供的联系方式与我们联系。

+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/credits_management.html b/templates/credits_management.html new file mode 100755 index 0000000..115b2ce --- /dev/null +++ b/templates/credits_management.html @@ -0,0 +1,246 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

鸣谢管理

+
+
+
+
+ + +
+
+ +
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+ {% for credit in credits %} +
+ {{ credit.name }} +
+

{{ credit.name }}

+ {{ credit.link }} +
+
+ + +
+
+ {% endfor %} +
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/donate_settings.html b/templates/donate_settings.html new file mode 100755 index 0000000..e73528f --- /dev/null +++ b/templates/donate_settings.html @@ -0,0 +1,907 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

赞赏设置

+
+
+
+
+ +
+ +
+ + +
+ +
+ +
+ + +
+ {% if settings.donate_image %} +
+ 当前赞赏二维码 +
+ {% endif %} +
+ +
+ + +
+ + +
+
+ + +
+
+
+ +

添加赞赏人

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+ +

赞赏人列表

+ +
+
+
+ 总金额:¥{{ total_amount|default('0.00') }} +
+
+
+
+ {% for donor in donors %} +
+
+ {{ donor.name }} + {{ donor.amount }} + {% if donor.message %} + {{ donor.message }} + {% endif %} + {% if donor.link %} + + + + + + {% endif %} +
+
+ + +
+
+ {% endfor %} +
+
+
+
+ + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/donors.html b/templates/donors.html new file mode 100755 index 0000000..fecf0fd --- /dev/null +++ b/templates/donors.html @@ -0,0 +1,292 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ + + +

赞赏名单

+
+ +
+ {% for donor in donors %} +
+
+
{{ donor.name }}
+
+ {% set amount = donor.amount.replace('¥', '').strip() %} + {% set float_amount = amount|float %} + ¥{{ "%.2f"|format(float_amount) }} +
+ {% if donor.message %} +
+
{{ donor.message }}
+ {% endif %} + {% if donor.link %} + + {% endif %} +
+
+ {% endfor %} +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/email_settings.html b/templates/email_settings.html new file mode 100755 index 0000000..0b4b278 --- /dev/null +++ b/templates/email_settings.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+ +
+ +
+
+
+ +

邮件设置

+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/error.html b/templates/error.html new file mode 100755 index 0000000..215704e --- /dev/null +++ b/templates/error.html @@ -0,0 +1,122 @@ + + + + + 404 - 页面未找到 + + + + + + +
+ +
404
+

页面未找到

+

抱歉,您访问的页面不存在或已被移除。

+ + + 返回首页 + +
+ + \ No newline at end of file diff --git a/templates/explore.html b/templates/explore.html new file mode 100755 index 0000000..b1e2c20 --- /dev/null +++ b/templates/explore.html @@ -0,0 +1,2189 @@ +{% extends "base.html" %} + +{% block content %} + + 心愿单通知弹窗 +
+
+ 🎉 APP已上线 + + +
+
+
+ + + + + + + + + + + + + +
+ + + +
+ +
+ 探索图片1 +
+
+ {% if today_apps %} + {% for app in today_apps %} +
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+
+

{{ app.name }}

+
+
+ {% endfor %} + {% else %} +
今日暂无新应用
+ {% endif %} +
+
+
+ + +
+ 鸿蒙操作系统5 +
+ + +
+ 探索图片2 +
+ + + + + + + +
+
+
+

热门应用

+
+ + 查看全部 + +
+
+ {% if hot_apps %} + {% for app in hot_apps[:5] %} +
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+
+

{{ app.name }}

+

{{ app.category_name }}

+
+
+ {% endfor %} + {% else %} +
暂无热门应用
+ {% endif %} +
+
+
+
+ + +
+ {% include 'nav_bar.html' %} +
+ + + + + +{% endblock %} diff --git a/templates/hot_apps.html b/templates/hot_apps.html new file mode 100755 index 0000000..fe1a08e --- /dev/null +++ b/templates/hot_apps.html @@ -0,0 +1,326 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ + + +

热门应用

+
+ +
+ {% for app in hot_apps %} +
+
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+
+
+

{{ app.name }}

+ {{ app.category_name }} +
+
+ {% if app.version and app.version.strip() %} + {{ app.version }} + {% endif %} +
+
+
+
+ {% endfor %} +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100755 index 0000000..e7c7b5b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,4213 @@ +{% extends "base.html" %} + +{% block content %} + + + + + + + + +
+ + +
+
+
+ +
+
{{ settings.site_notice }}
+
+ +
+
+ +
+ + +
+
+ {% if settings and settings.qq_group_link %} + + {% endif %} + + {% if settings and settings.wechat_group_qrcode %} + + {% endif %} +
+ +
+ +
+
+ + +{% if search %} + + + +
+ +
+ +
+ {% set mobile_apps = [] %} + {% for app in apps %} + {% if app.platform and ('mobile' in app.platform.split(',')) %} + {% set _ = mobile_apps.append(app) %} + {% endif %} + {% endfor %} + + {% if mobile_apps|length == 0 %} +
+
+ +
+
+ 未找到相关应用 +
+ {% if settings.feedback_link %} + + {% endif %} +
+ {% else %} + {% for app in mobile_apps %} +
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+
+

{{ app.name }}

+
+
+ {% endfor %} + {% endif %} +
+ + +{% else %} + + + +
+ +
+ +
+

全部应用

+ {{ apps|length }}个应用 +
+
+ + {% for category in categories %} + {% set category_apps = [] %} + {% for app in apps %} + {% if app.category_id == category.id %} + {% set _ = category_apps.append(app) %} + {% endif %} + {% endfor %} + + {% if category_apps|length > 0 %} +
+ +
+

{{ category.name }}

+ {{ category_apps|length }}个应用 +
+
+ {% endif %} + {% endfor %} +
+ + + + +{% endif %} +
+ + + + + + + + + +{% if settings.donate_enabled == '1' %} +
+

赞赏名单

+ {% if donors_section %} + {% set latest_donor = donors_section|sort(attribute='created_at', reverse=true)|first %} + {% if latest_donor and latest_donor.created_at %} +

更新时间:{{ latest_donor.created_at|datetime_format }}

+ {% endif %} + {% endif %} +
+ {# 移动端显示 #} + {% for donor in donors_section[:mobile_limit] %} +
+
+
{{ donor.name }}
+
+ {% set amount = donor.amount.replace('¥', '').strip() %} + {% set float_amount = amount|float %} + ¥{{ "%.2f"|format(float_amount) }} +
+
+ {% if donor.link %} + + {% endif %} +
+ {% endfor %} + {% if donors_section|length > mobile_limit %} +
+
+
查看全部
+
共{{ donors_section|length }}条
+
+
+ {% endif %} + + {# PC端��示 #} + {% for donor in donors_section[:pc_limit] %} +
+
+
{{ donor.name }}
+
+ {% set amount = donor.amount.replace('¥', '').strip() %} + {% set float_amount = amount|float %} + ¥{{ "%.2f"|format(float_amount) }} +
+
+ {% if donor.link %} + + {% endif %} +
+ {% endfor %} + {% if donors_section|length > pc_limit %} +
+
+
查看全部
+
共{{ donors_section|length }}条
+
+
+ {% endif %} +
+
+{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endblock %} + + {% block head %} + + + + + +
+ {% if settings and settings.qq_group_link %} + + {% endif %} + + {% if settings and settings.wechat_group_qrcode %} + + {% endif %} +
+ + + {% endblock %} + + + + +{% include 'nav_bar.html' %} + + + + +{% block footer %}{% endblock %} + diff --git a/templates/invite_leaderboard.html b/templates/invite_leaderboard.html new file mode 100755 index 0000000..5ac8dd2 --- /dev/null +++ b/templates/invite_leaderboard.html @@ -0,0 +1,1062 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ + + +

邀请榜单

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {% for user in leaderboard %} +
+
+ {% if loop.index == 1 %} +
+ +
+ {% elif loop.index == 2 %} +
+ +
+ {% elif loop.index == 3 %} +
+ +
+ {% else %} + {{ loop.index }} + {% endif %} +
+ +
+ {% else %} +
+ +

暂无邀请记录

+
+ {% endfor %} +
+
+ + + +
+ +
+ + + + +{% endblock %} + +{% block footer %}{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100755 index 0000000..15bae15 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/templates/more.html b/templates/more.html new file mode 100755 index 0000000..99118c9 --- /dev/null +++ b/templates/more.html @@ -0,0 +1,1275 @@ +{% extends "base.html" %} + +{% block content %} + + + + + + + + + + + + +
+ +
+ +
+ +
+ {% if session.get('huawei_user') %} + +
+ +
+ {% if session.huawei_user.avatar %} + {{ session.huawei_user.name }} + {% else %} + + {% endif %} +
+ +
+ +
+
+ {% else %} + +
+
+ +
+ +
+ +
+
+ {% endif %} +
+ + +
+ +
+
+ +
+
+ 心愿单 +
+
+ + + + + + + + + + + + + + + + +
+
+ +
+
+ Hap包投稿 +
+
+ + + + + + + + + + + + + + +
+ + +
+
+
+ + +
+
+ 深色模式 + 浅色模式 +
+
+
+
+ +
+
+ 使用条款 +
+
+
+
+ +
+
+ 隐私政策 +
+
+
+
+ +
+
+ Cookie政策 +
+
+
+ + +
开发中
+
+ + +{% include 'nav_bar.html' %} + + + + + + + + + + + + +
+
+ + +
+
+ + + + + + + + + + +{% endblock %} + +{% block footer %}{% endblock %} \ No newline at end of file diff --git a/templates/my_comments.html b/templates/my_comments.html new file mode 100755 index 0000000..1d90170 --- /dev/null +++ b/templates/my_comments.html @@ -0,0 +1,572 @@ +{% extends "base.html" %} + +{% block title %}我的评论{% endblock %} + +{% block content %} +
+
+ + + +

我的评论

+
+ +
+
+ + + + +
+
+ +
+
+ +
+ +
+
+ + + +{% block styles %} + +{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/templates/nav_bar.html b/templates/nav_bar.html new file mode 100755 index 0000000..932c570 --- /dev/null +++ b/templates/nav_bar.html @@ -0,0 +1,76 @@ + + + \ No newline at end of file diff --git a/templates/new_apps.html b/templates/new_apps.html new file mode 100755 index 0000000..a7559b0 --- /dev/null +++ b/templates/new_apps.html @@ -0,0 +1,784 @@ +{% extends "base.html" %} + +{% block content %} + + + + + + + + + +
+
+
+ +
+
+ + + +

{{ date_text }}

+
+ +
+ 今日 + 昨日 + 前日 +
+ + {% if today_apps %} +
+ {% for app in today_apps %} +
+
+
+ {% if 'http' in app.icon_path %} + {{ app.name }} + {% else %} + {{ app.name }} + {% endif %} +
+
+
+

{{ app.name }}

+
+
+
+
+ {% endfor %} +
+ {% else %} +
+ +

今日无上新应用

+
+ {% endif %} +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/policy_base.html b/templates/policy_base.html new file mode 100755 index 0000000..e3633dd --- /dev/null +++ b/templates/policy_base.html @@ -0,0 +1,502 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+ + + +

{% block policy_header %}{% endblock %}

+
+ + +
+ +
+ +

{% block policy_title %}{% endblock %}

+
+ + + {% block policy_content %}{% endblock %} +
+
+ + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/privacy.html b/templates/privacy.html new file mode 100755 index 0000000..b7430d6 --- /dev/null +++ b/templates/privacy.html @@ -0,0 +1,293 @@ +{% extends "policy_base.html" %} + +{% block policy_header %}隐私政策{% endblock %} +{% block policy_icon %}fas fa-shield-alt{% endblock %} +{% block policy_title %}隐私政策{% endblock %} + +{% block policy_content %} +
+

最近更新:2025年1月15日

+
+ +
+

引言

+

欢迎使用 NEXT Store。我们非常重视您的隐私保护,并致力于保护您的个人信息安全。本隐私政策旨在帮助您了解:

+
    +
  • 我们如何收集和使用您的个人信息
  • +
  • 我们如何存储和保护这些信息
  • +
  • 您享有的权利和选择
  • +
+

请您仔细阅读并确保完全理解本隐私政策的所有内容。如果您不同意本政策的任何内容,请停止使用我们的服务。

+
+ +
+

隐私保护原则

+

我们遵循以下隐私保护原则:

+
    +
  • 最小化收集:仅收集提供服务必要的信息
  • +
  • 透明管理:清晰说明信息用途
  • +
  • 用户控制:提供信息管理渠道
  • +
  • 安全保护:采用先进加密技术
  • +
  • 合规性:严格遵守国家相关法律法规
  • +
+
+ +
+

信息收集

+

账号授权信息

+

当您使用华为账号登录时,我们会收集以下信息:

+
    +
  • 华为账号的基本信息(用户ID)
  • +
  • 您的昵称
  • +
  • 电子邮件地址
  • +
  • 头像图片
  • +
+ +

使用数据

+

我们会收集您使用服务时产生的数据:

+
    +
  • 浏览记录和搜索历史
  • +
  • 设备信息(操作系统、浏览器类型)
  • +
  • 访问日志(IP地址、访问时间)
  • +
  • 心愿单数据
  • +
  • 主题偏好设置
  • +
+ +

自动收集的信息

+

当您访问和使用我们的网站时,我们会自动收集:

+
    +
  • 网络连接信息(如HTTP头信息、请求方法)
  • +
  • 设备标识符和硬件信息
  • +
  • 浏览器设置和语言偏好
  • +
  • 页面访问统计和交互数据
  • +
+
+ +
+

信息使用

+

我们使用收集的信息用于:

+
    +
  • 提供、维护和改进我们的服务
  • +
  • 实现心愿单功能
  • +
  • 发送应用更新通知
  • +
  • 提供个性化的用户体验
  • +
  • 分析网站使用情况
  • +
  • 预防和处理安全问题
  • +
+ +

邮件通知

+

我们可能会向您发送以下类型的邮件:

+
    +
  • 心愿单应用上架通知
  • +
  • 账号安全相关通知
  • +
  • 服务更新和政策变更通知
  • +
+
+ +
+

信息共享

+

我们承诺:

+
    +
  • 不会出售您的个人信息
  • +
  • 不会与第三方共享您的个人信息,除非: +
      +
    • 获得您的明确同意
    • +
    • 法律法规要求
    • +
    • 保护我们或用户的权利和安全
    • +
    +
  • +
+ +

数据处理者

+

在某些情况下,我们可能会使用第三方服务提供商来协助我们处理数据:

+
    +
  • 网站托管服务提供商
  • +
  • 数据分析服务提供商
  • +
  • 电子邮件服务提供商
  • +
+

这些服务提供商仅在必要范围内访问和处理数据,并受到严格的保密义务约束。

+
+ +
+

特殊情况下的信息披露

+

在以下特殊情况下,我们可能会披露您的个人信息:

+
    +
  • 获得您的明确授权
  • +
  • 根据法律法规的要求
  • +
  • 为保护我们的合法权益
  • +
  • 在紧急情况下保护他人的人身安全
  • +
+
+ +
+

信息存储与安全

+

我们采取以下措施保护您的信息:

+
    +
  • 使用安全的数据存储技术
  • +
  • 实施访问控制机制
  • +
  • 定期安全评估和更新
  • +
  • 员工保密培训
  • +
+ +

数据保留

+

我们会在以下期限内保留您的个人信息:

+
    +
  • 账号相关信息:在您的账号存续期间
  • +
  • 心愿单数据:直到您删除或账号注销
  • +
  • 日志信息:最长保留6个月
  • +
+
+ +
+

您的权利

+

您对您的个人信息享有以下权利:

+
    +
  • 访问您的个人信息
  • +
  • 更正不准确的信息
  • +
  • 删除您的账号和相关数据
  • +
  • 撤回同意授权
  • +
  • 导出您的数据
  • +
  • 限制某些信息的处理
  • +
+ +

行使权利的方式

+
    +
  • 通过个人中心直接管理您的信息
  • +
  • 通过网站提供的联系方式申请处理
  • +
  • 我们将在15个工作日内响应您的请求
  • +
+
+ +
+

Cookie 使用

+

我们使用 Cookie 和类似技术来:

+
    +
  • 保持您的登录状态
  • +
  • 记住您的偏好设置
  • +
  • 提供个性化体验
  • +
  • 改善网站性能
  • +
+

详细信息请参见我们的 Cookie 政策

+

您可以通过浏览器设置随时控制或删除 Cookie。但请注意,禁用 Cookie 可能会影响网站的某些功能。

+
+ +
+

未成年人保护

+

我们的服务面向成年用户。如果发现我们无意中收集了未成年人的信息,我们将:

+
    +
  • 及时删除相关信息
  • +
  • 终止相关账号的服务
  • +
  • 采取必要措施防止再次收集
  • +
+ +

家长/监护人须知

+

如果您是未成年人的父母或监护人,发现您的孩子未经您的同意向我们提供了个人信息,请联系我们:

+
    +
  • 我们将根据您的要求删除相关信息
  • +
  • 为您提供必要的账号管理协助
  • +
+
+ +
+

隐私政策更新

+

我们可能会更新本隐私政策:

+
    +
  • 重大变更会通过网站公告通知
  • +
  • 继续使用我们的服务表示您同意更新后的政策
  • +
  • 建议您定期查看本政策了解最新变化
  • +
+ +

更新通知方式

+

当本政策发生重大变更时,我们会:

+
    +
  • 在网站显著位置发布更新通知
  • +
  • 向您发送电子邮件通知
  • +
  • 在更新生效前预留合理的时间供您选择是否继续使用我们的服务
  • +
+
+ +
+

联系我们

+

如果您对本隐私政策有任何疑问或建议,请通过以下方式联系我们:

+
    +
  • 通过网站提供的反馈功能
  • +
  • 加入我们的用户群组
  • +
  • 发送邮件至:nvveex@petalmail.com
  • +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/recommend.html b/templates/recommend.html new file mode 100755 index 0000000..056fc8a --- /dev/null +++ b/templates/recommend.html @@ -0,0 +1,589 @@ +{% extends "base.html" %} + +{% block content %} + +
+ × + +
+
+
+ 书立 +
+
+

书立安装

+
+
+
+ +
+ 书立是一款本地优先的富文本笔记软件,拥有出众的性能,丝滑的动画,小巧的体积,丰富的功能。 +
+ +
+
+
🎨
+
+
富文本编辑器
+
+ 编辑器作为笔记软件的核心,其强大与否直接决定了软件的上限,书立的编辑器支持大量富文本结构,并且渲染速度很快,可以编辑超过千万字的文档。 +
+
+
+ +
+
+ 富文本编辑器展示 +
+
+ +
+
🎋
+
+
富目录树
+
+ 书立的目录树也是富文本,笔记的标题与大纲自动生成目录树,对标题与大纲设置的富文本会自动渲染到目录中,由此可以设计出丰富的目录结构,如:图文目录,表情目录,任务目录,彩色目录。 +
+
+
+ +
+
+ 富目录树展示 +
+
+ +
+
🗒
+
+
嵌套表格
+
+ 嵌套表格是书立的一大亮点,支持表格的笔记软件有很多,支持表格内富文本编辑的也有一些,支持表格内嵌套表格的软件很少,书立是少数支持表格嵌套还能富文本编辑的软件之一。书立的表格不仅可以规划信息结构,还可以做UI布局,比如使用表格嵌套+合并单元格设计一个康奈尔笔记模板,或者做四象限任务管理。 +
+
+
+ +
+
+ 嵌套表格展示 +
+
+
+ +
+
🪐️
+
+
体积小巧
+
+ 鸿蒙移动端安装包体积只有 827kb(富目录树 + 富文本编辑器 + 富文本嵌套表格 + 全文搜索 + 导入导出 + 浮动目录 ) +
+
+
+
+ + +
+
+ +

书立

+

实用工具 | 笔记

+

北京源码觉醒科技有限公司

+
+
+ 安装 +
+
+ + +
+ +
+ + + + + +{% endblock %} +{% block footer %}{% endblock %} \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100755 index 0000000..6c1f389 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/templates/search_results.html b/templates/search_results.html new file mode 100755 index 0000000..0519ecb --- /dev/null +++ b/templates/search_results.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/site_settings.html b/templates/site_settings.html new file mode 100755 index 0000000..c93e7ba --- /dev/null +++ b/templates/site_settings.html @@ -0,0 +1,520 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+ +
+ + + +
+ + +
+ +
+
+

基础设置

+

设置站点的基本信息和通知内容

+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + 启用网页黑白效果(用于特殊纪念日) +
+
+ + +
+
+ + +
+
+

群组设置

+

管理QQ群和微信群的相关配置

+
+
+
+ +
+
+ +

QQ群设置

+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {% if settings.qq_group_qrcode %} +
+ QQ群二维码 +
+ {% endif %} +
+
+
+ + +
+
+ +

微信群设置

+
+
+
+ + +
+ +
+ + + {% if settings.wechat_group_qrcode %} +
+ 微信群二维码 +
+ {% endif %} +
+
+
+
+ +
+
+ + +
+
+

水印设置

+

设置应用图标的水印文本

+
+
+
+ + +
+ +
+ + +
+ + +
+
+
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/tablet_filter.html b/templates/tablet_filter.html new file mode 100755 index 0000000..359edd6 --- /dev/null +++ b/templates/tablet_filter.html @@ -0,0 +1,299 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

平板应用筛选

+
+
+ +
+ + +
+ + +
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/terms.html b/templates/terms.html new file mode 100755 index 0000000..4b235b3 --- /dev/null +++ b/templates/terms.html @@ -0,0 +1,298 @@ +{% extends "policy_base.html" %} + +{% block policy_header %}使用条款{% endblock %} +{% block policy_icon %}fas fa-file-alt{% endblock %} +{% block policy_title %}使用条款{% endblock %} + +{% block policy_content %} +
+

最近更新:2025年1月15日

+
+ +
+

接受条款

+

欢迎使用 NEXT Store!访问和使用我们的服务,表示您接受这些使用条款。如果您不同意这些条款的任何部分,请停止使用我们的服务。我们保留随时修改这些条款的权利,修改后的条款将立即生效。

+
+ +
+

服务说明

+

NEXT Store 是一个应用展示平台,我们提供以下服务:

+
    +
  • 应用信息的浏览和搜索
  • +
  • 应用分类导航和推荐
  • +
  • 用户交互和反馈功能
  • +
  • 应用更新通知服务
  • +
  • 主题切换等个性化功能
  • +
  • 社区交流和分享功能
  • +
+
+ +
+

服务范围与限制

+

服务适用范围

+

本条款适用于 NEXT Store 提供的所有服务,包括但不限于:

+
    +
  • 网页端服务
  • +
  • 移动应用程序
  • +
  • API 接口
  • +
  • 社区交流平台
  • +
+ +

服务使用限制

+

我们保留以下服务使用限制权利:

+
    +
  • 对用户的使用行为设置合理限制
  • +
  • 要求用户提供真实、有效的注册信息
  • +
  • 拒绝为不符合条件的用户提供服务
  • +
  • 对违规用户采取警告、暂停或永久封禁等措施
  • +
+
+ +
+

服务变更

+

我们保留随时修改或终止服务的权利,包括但不限于:

+
    +
  • 调整网站功能和界面
  • +
  • 更新服务内容和范围
  • +
  • 变更使用条件和限制
  • +
  • 调整技术要求和标准
  • +
+

对于服务的任何变更,我们将通过适当方式通知用户。继续使用本服务即表示您接受这些变更。

+
+ +
+

用户内容

+

关于用户在网站上发布、上传或分享的内容:

+
    +
  • 您保留您原创内容的所有权利
  • +
  • 您授予我们使用、存储和展示该内容的许可
  • +
  • 您确保您有权分享该内容
  • +
  • 您同意对您发布的内容负责
  • +
+ +

内容审核

+

我们保留但不承担义务来审核、筛选或删除任何用户内容。我们可能:

+
    +
  • 删除违反本协议的内容
  • +
  • 处理侵权投诉
  • +
  • 配合执法部门的合法要求
  • +
+
+ +
+

用户责任

+

使用我们的服务时,您同意:

+
    +
  • 提供真实、准确、完整的信息
  • +
  • 遵守所有适用的法律和法规
  • +
  • 不从事任何可能损害网站运行的行为
  • +
  • 不进行任何未经授权的访问或使用
  • +
  • 不发布任何违法、侵权或不当内容
  • +
  • 不使用自动化工具干扰网站正常运行
  • +
  • 不传播恶意软件或有害信息
  • +
  • 保护您的账户安全
  • +
+
+ +
+

禁止行为

+

您承诺并同意以合法道德的方式依照本协议访问并使用本服务。您不得利用本服务从事以下行为:

+ +

违法违规内容

+

上传、下载、存储、发布、传输或以其他方式提供如下法律、法规和政策禁止的内容:

+
    +
  • 反对宪法所确定的基本原则的内容
  • +
  • 危害国家安全,泄露国家秘密,颠覆国家政权,破坏国家统一的内容
  • +
  • 损害国家荣誉和利益的内容
  • +
  • 煽动民族仇恨、民族歧视,破坏民族团结的内容
  • +
  • 破坏国家宗教政策,宣扬邪教和封建迷信的内容
  • +
  • 散布谣言,扰乱社会秩序,破坏社会稳定的内容
  • +
  • 散布淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的内容
  • +
  • 侮辱或者诽谤他人,侵害他人合法权益的内容
  • +
  • 危害社会公德或者民族优秀文化传统的内容
  • +
+ +

侵权行为

+
    +
  • 侵害他人隐私权、商业秘密、商标权、著作权、专利权等合法权益
  • +
  • 未经授权访问或尝试访问本服务的系统或网络
  • +
  • 复制、修改或分发本服务的内容
  • +
  • 冒充他人或虚假声明与他人的关系
  • +
  • 收集用户信息或使用自动化手段访问本服务
  • +
+ +

技术滥用

+
    +
  • 分解、解码或逆向开发本服务
  • +
  • 植入病毒或其他恶意代码
  • +
  • 使用爬虫、脚本等自动化工具
  • +
  • 干扰或损害服务器正常运行
  • +
  • 尝试破解或入侵系统
  • +
+ +

商业滥用

+
    +
  • 未经许可将本服务用于商业用途
  • +
  • 从事任何非法交易活动
  • +
  • 发布虚假或误导性商业信息
  • +
  • 参与洗钱、套现或传销活动
  • +
+
+ +
+

处罚措施

+

如果发现或收到举报您违反上述规定,我们有权:

+
    +
  • 独立判断并删除、屏蔽相关内容
  • +
  • 暂停或终止您的使用权限
  • +
  • 保存相关证据并向有关部门报告
  • +
  • 要求您承担相应的法律责任
  • +
  • 要求您赔偿可能造成的损失
  • +
+

您理解并同意,对于违反协议的行为,您应独自承担由此产生的一切法律责任,包括但不限于对第三方的赔偿责任。

+
+ +
+

知识产权

+

网站上的现有内容,包括但不限于:

+
    +
  • 文本、文章和描述
  • +
  • 图像、图标和标志
  • +
  • 界面设计和布局
  • +
  • 程序代码和功能
  • +
  • 数据库内容
  • +
+

均受适用的知识产权法律保护。未经明确许可,不得复制、修改、传播或用于商业目的。

+
+ +
+

免责声明

+

我们的服务按"现状"提供,不提供任何明示或暗示的保证。具体而言:

+
    +
  • 我们不保证服务永久可用或不会中断
  • +
  • 我们不对服务的及时性、安全性或错误做出保证
  • +
  • 我们不对用户产生的任何直接或间接损失负责
  • +
  • 我们不对第三方链接的内容负责
  • +
  • 我们保留随时修改或终止服务的权利
  • +
+
+ +
+

隐私保护

+

我们重视您的隐私保护,具体请参见我们的隐私政策。使用我们的服务即表示您同意:

+
    +
  • 我们按照隐私政策收集和使用您的信息
  • +
  • 我们可能使用 Cookie 和类似技术
  • +
  • 我们可能向您发送服务相关通知
  • +
+
+ +
+

条款修改

+

我们保留随时修改这些条款的权利,修改后:

+
    +
  • 我们会在网站上发布更新的条款
  • +
  • 重大更改将通过网站通知
  • +
  • 继续使用我们的服务即表示您接受修改后的条款
  • +
  • 如果您不同意修改后的条款,应停止使用我们的服务
  • +
+
+ +
+

联系我们

+

如果您对这些条款有任何疑问或建议,请通过网站提供的联系方式与我们联系。我们会认真对待每一个反馈,并在合理的时间内回复您的询问。

+
+ +
+

争议解决与法律适用

+

对于因使用我们的服务而产生的任何争议:

+
    +
  • 双方应首先通过友好协商解决
  • +
  • 协商无法解决的,提交至网站运营地有管辖权的人民法院
  • +
  • 适用中华人民共和国法律
  • +
+
+ +
+

其他规定

+
    +
  • 本协议的任何条款被认定无效的,不影响其他条款的效力
  • +
  • 我们未行使或执行本协议中的任何权利或规定,不构成对该权利或规定的放弃
  • +
  • 本协议中的标题仅为方便阅读,不影响协议的解释
  • +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/user_management.html b/templates/user_management.html new file mode 100755 index 0000000..1eee0aa --- /dev/null +++ b/templates/user_management.html @@ -0,0 +1,363 @@ +{% extends "base.html" %} + +{% block content %} +{% include 'admin_nav.html' %} + +
+
+
+
+
+ +

添加管理员

+
+
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+
+ +
+
+
+ +

管理员列表

+
+ +
+ +
+ + + + + + + + + + + + + + {% for admin in admins %} + + + + + + + + + + + + + + {% endfor %} + +
用户名角色创建时间最后登录登录次数最后操作操作
{{ admin.username }}{{ '超级管理员' if admin.is_superadmin else '管理员' }}{{ admin.created_at }}{{ (admin.last_login|default('从未登录', true))|datetime_format }}{{ admin.login_count }}{{ admin.last_action or '无' }} +
+ {% if not admin.is_superadmin %} + + + + + + + + + + {% endif %} +
+
+
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/user_profile.html b/templates/user_profile.html new file mode 100755 index 0000000..d4ee867 --- /dev/null +++ b/templates/user_profile.html @@ -0,0 +1,556 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ + + +

个人信息

+
+ +
+ +
+ +
+
+
+
+
+ {% if session.huawei_user.avatar %} + {{ session.huawei_user.name }} + {% else %} + + {% endif %} +
+
+
{{ session.huawei_user.name }}
+
个人资料
+
+ +
+
+
+ + 昵称 +
+
+ +
+
+
+
+ + 邮箱 +
+
+ +
+
+
+ +
+ + +
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/wiki.html b/templates/wiki.html new file mode 100755 index 0000000..f4c6f41 --- /dev/null +++ b/templates/wiki.html @@ -0,0 +1,1708 @@ +{% extends "base.html" %} + +{% block title %}Wiki{% endblock %} + +{% block head %} +{{ super() }} + + + + + +{% endblock %} + +{% block content %} +
+
+
+
+
+ +
+
+
+ 资讯 +
+
+ 更新日志 +
+
+
+
+
+ +
+
+ + +
+
+ ⌘K +
+
+ + + + + + + +
+
+ +
+
+ {% for entry in entries %} +
+
+
+

{{ entry.title }}

+
+ + {% if entry.wiki_type == 'version' %} + {{ entry.version }} - {{ entry.second_level }} - {{ entry.third_level }} + {% else %} + {{ entry.second_level }} + {% endif %} + + +
+
+
+
+ {% endfor %} +
+
+
+ +{% include 'nav_bar.html' %} + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/wiki_detail.html b/templates/wiki_detail.html new file mode 100755 index 0000000..ee49089 --- /dev/null +++ b/templates/wiki_detail.html @@ -0,0 +1,838 @@ +{% extends "base.html" %} + +{% block title %}{{ entry.title }} - Wiki{% endblock %} + +{% block head %} +{{ super() }} + + + + + + + + + + + +{% endblock %} + +{% block content %} +
+
+ + 返回 + + +
+ +
+ +

{{ entry.title }}

+
+ + +
+
+ + {% if entry.images and entry.images|length > 0 %} + + {% endif %} + + + + + +
+
+ + + +
+ +
+
+
+ {% if session.get('huawei_user') and session.huawei_user.avatar %} + {{ session.huawei_user.name }} + {% else %} + + {% endif %} +
+
+ + +
+
+
+ +
+
+ +
+

暂无功能建议,快来提出你的创意吧!

+
+
+ +
+ +
+

暂无已知问题,系统运行良好!

+
+
+ +
+ +
+

还没有人分享新增功能,快来抢沙发!

+
+
+
+
+
+
+
+
+ + +
+ +{% endblock %} + +{% block footer %} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/wishlist.html b/templates/wishlist.html new file mode 100755 index 0000000..3ab8bc6 --- /dev/null +++ b/templates/wishlist.html @@ -0,0 +1,1069 @@ +{% extends "base.html" %} + +{% block head %} + + +{% endblock %} + +{% block content %} +
+
+ + + +

心愿单

+ +
+ + +
+ +
+ +
+ + + + +
+
+ + + {% if session.huawei_user.email %} + 应用上架后将通过邮箱 {{ session.huawei_user.email }} 通知您 + {% else %} + 绑定邮箱后可以收到应用上架通知 + + {% endif %} + +
+
+ + +
+
+ + + {% if session.huawei_user.unlocked_features %} + 您已成功邀请3位好友,已解锁20个心愿单名额 + {% else %} + 邀请3位好友使用,即可解锁20个心愿单名额(已邀请 0/3 位) + {% endif %} + + +
+
+ +
+ +
+
+
+ + + + + + + + + + + +{% endblock %} + +{% block footer %}{% endblock %} \ No newline at end of file diff --git a/华为应用市场爬虫系统开发文档.md b/华为应用市场爬虫系统开发文档.md new file mode 100644 index 0000000..676c166 --- /dev/null +++ b/华为应用市场爬虫系统开发文档.md @@ -0,0 +1,3447 @@ +# 华为应用市场爬虫系统开发文档 + +> 基于原 Rust 项目的 Python + MySQL + Vue3 重构指南 + +## 📋 目录 + +- [1. 项目概述](#1-项目概述) +- [2. 系统架构](#2-系统架构) +- [3. 数据源分析](#3-数据源分析) +- [4. 数据库设计](#4-数据库设计) +- [5. 后端开发](#5-后端开发) +- [6. 前端开发](#6-前端开发) +- [7. 部署指南](#7-部署指南) + +--- + +## 1. 项目概述 + +### 1.1 项目目标 + +开发一个华为应用市场(AppGallery)数据采集与可视化系统,实现: +- 自动爬取华为应用市场的应用信息 +- 存储应用的基本信息、版本历史、下载量、评分等数据 +- 提供 Web 界面展示数据统计、排行榜、趋势分析 +- 支持用户搜索、筛选、投稿应用 + +### 1.2 技术栈选型 + +**后端:** +- Python 3.10+ +- FastAPI (Web 框架) +- SQLAlchemy (ORM) +- MySQL 8.0+ +- APScheduler (定时任务) +- httpx / aiohttp (异步 HTTP 客户端) + +**前端:** +- Vue 3 + TypeScript +- Vite (构建工具) +- Element Plus / Ant Design Vue (UI 组件库) +- ECharts / Chart.js (图表库) +- Axios (HTTP 客户端) +- Pinia (状态管理) + +**部署:** +- Docker + Docker Compose +- Nginx (反向代理) +- Gunicorn / Uvicorn (ASGI 服务器) + + +--- + +## 2. 系统架构 + +### 2.1 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 用户浏览器 │ +└────────────────────────┬────────────────────────────────────┘ + │ HTTP/HTTPS + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Nginx (反向代理) │ +└──────────┬──────────────────────────────────┬───────────────┘ + │ │ + │ /api/* │ /* + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────┐ +│ FastAPI 后端服务 │ │ Vue3 前端静态资源 │ +│ - REST API │ │ - SPA 应用 │ +│ - 数据查询 │ │ - 数据可视化 │ +│ - 爬虫调度 │ └──────────────────────────┘ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ ┌──────────────────────────┐ +│ MySQL 数据库 │◄─────────│ 爬虫调度器 │ +│ - 应用信息 │ │ - APScheduler │ +│ - 历史数据 │ │ - 定时同步 │ +│ - 统计数据 │ │ - 批量处理 │ +└──────────────────────┘ └──────────┬───────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ 华为应用市场 API │ + │ - 应用信息接口 │ + │ - 评分详情接口 │ + └──────────────────────────┘ +``` + +### 2.2 核心模块 + +1. **爬虫模块** - 负责从华为 API 获取数据 +2. **数据处理模块** - 数据清洗、去重、入库 +3. **API 服务模块** - 提供 RESTful API +4. **调度模块** - 定时任务和批量处理 +5. **前端展示模块** - 数据可视化和交互 + + +--- + +## 3. 数据源分析 + +### 3.1 华为应用市场 API + +**基础信息:** +- API Base URL: `https://web-drcn.hispace.dbankcloud.com/edge` +- 需要动态获取认证 Token(interface-code 和 identity-id) +- Token 有效期约 10 分钟,需定期刷新 + +### 3.2 主要接口 + +#### 3.2.1 获取应用基本信息 + +**接口地址:** `POST /webedge/appinfo` + +**请求头:** +```http +Content-Type: application/json +User-Agent: HuaweiMarketCrawler/1.0 +interface-code: {动态获取的token} +identity-id: {动态获取的token} +``` + +**请求体(按包名查询):** +```json +{ + "pkgName": "com.huawei.hmsapp.appgallery", + "locale": "zh_CN" +} +``` + +**请求体(按应用ID查询):** +```json +{ + "appId": "C1164531384803416384", + "locale": "zh_CN" +} +``` + +**响应示例:** +```json +{ + "appId": "C1164531384803416384", + "name": "应用市场", + "pkgName": "com.huawei.hmsapp.appgallery", + "devId": "260086000000068459", + "developerName": "华为软件技术有限公司", + "devEnName": "Huawei Software Technologies Co., Ltd.", + "kindName": "工具", + "version": "6.3.2.302", + "size": 76591487, + "downCount": "14443706", + "rateNum": "125000", + "hot": "4.5", + "icon": "https://...", + "briefDes": "应用市场,点亮精彩生活", + "description": "...", + "releaseDate": 1234567890000, + "targetSdk": "12", + "minsdk": "9", + ... +} +``` + +#### 3.2.2 获取应用评分详情 + +**接口地址:** `POST /harmony/page-detail` + +**请求体:** +```json +{ + "pageId": "webAgAppDetail|C1164531384803416384", + "pageNum": 1, + "pageSize": 100, + "zone": "" +} +``` + +**响应示例:** +```json +{ + "pages": [{ + "data": { + "cardlist": { + "layoutData": [{ + "type": "fl.card.comment", + "data": [{ + "starInfo": "{\"averageRating\":\"4.5\",\"oneStarRatingCount\":100,\"twoStarRatingCount\":200,...}" + }] + }] + } + } + }] +} +``` + +### 3.3 Token 获取策略 + +Token 需要从华为网页端动态获取,建议实现方式: + +1. **方案一:** 使用 Selenium/Playwright 模拟浏览器访问获取 +2. **方案二:** 逆向分析 JS 代码,实现 Token 生成算法 +3. **方案三:** 定期手动更新 Token(不推荐) + +**参考实现(伪代码):** +```python +import httpx +from playwright.async_api import async_playwright + +async def get_huawei_token(): + async with async_playwright() as p: + browser = await p.chromium.launch() + page = await browser.new_page() + + # 拦截网络请求获取 token + tokens = {} + async def handle_request(request): + if 'interface-code' in request.headers: + tokens['interface_code'] = request.headers['interface-code'] + tokens['identity_id'] = request.headers['identity-id'] + + page.on('request', handle_request) + await page.goto('https://appgallery.huawei.com/') + await page.wait_for_timeout(3000) + await browser.close() + + return tokens +``` + +### 3.4 数据字段说明 + +**核心字段:** +- `appId` - 应用唯一标识(长度>15为鸿蒙应用) +- `pkgName` - 包名(唯一) +- `name` - 应用名称 +- `developerName` - 开发者名称 +- `downCount` - 下载量(字符串格式,如 "1000000+") +- `rateNum` - 评分人数 +- `hot` - 热度评分 +- `version` - 版本号 +- `size` - 应用大小(字节) +- `releaseDate` - 发布时间(毫秒时间戳) +- `targetSdk` / `minsdk` - SDK 版本 + +**注意事项:** +1. 部分字段可能为空,需要设置默认值 +2. 下载量可能包含 "+" 号,需要清洗 +3. 某些应用(元服务)包名以 `com.atomicservice` 开头,无评分数据 +4. JSON 中可能包含 `\0` 字符,需要清理 + + +--- + +## 4. 数据库设计 + +### 4.1 MySQL 表结构 + +#### 4.1.1 应用基本信息表 (app_info) + +```sql +CREATE TABLE `app_info` ( + `app_id` VARCHAR(50) PRIMARY KEY COMMENT '应用唯一ID', + `alliance_app_id` VARCHAR(50) COMMENT '联盟应用ID', + `name` VARCHAR(255) NOT NULL COMMENT '应用名称', + `pkg_name` VARCHAR(255) NOT NULL UNIQUE COMMENT '应用包名', + `dev_id` VARCHAR(50) NOT NULL COMMENT '开发者ID', + `developer_name` VARCHAR(255) NOT NULL COMMENT '开发者名称', + `dev_en_name` VARCHAR(255) COMMENT '开发者英文名称', + `supplier` VARCHAR(255) COMMENT '供应商名称', + `kind_id` INT NOT NULL COMMENT '应用分类ID', + `kind_name` VARCHAR(100) NOT NULL COMMENT '应用分类名称', + `tag_name` VARCHAR(255) COMMENT '标签名称', + `kind_type_id` INT NOT NULL COMMENT '类型ID', + `kind_type_name` VARCHAR(100) NOT NULL COMMENT '类型名称', + `icon_url` TEXT NOT NULL COMMENT '应用图标URL', + `brief_desc` TEXT NOT NULL COMMENT '简短描述', + `description` LONGTEXT NOT NULL COMMENT '应用详细描述', + `privacy_url` TEXT NOT NULL COMMENT '隐私政策链接', + `ctype` INT NOT NULL COMMENT '客户端类型', + `detail_id` VARCHAR(100) NOT NULL COMMENT '详情页ID', + `app_level` INT NOT NULL COMMENT '应用等级', + `jocat_id` INT NOT NULL COMMENT '分类ID', + `iap` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否含应用内购买', + `hms` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否依赖HMS', + `tariff_type` VARCHAR(50) NOT NULL COMMENT '资费类型', + `packing_type` INT NOT NULL COMMENT '打包类型', + `order_app` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否预装应用', + `denpend_gms` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否依赖GMS', + `denpend_hms` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否依赖HMS', + `force_update` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否强制更新', + `img_tag` VARCHAR(50) NOT NULL COMMENT '图片标签', + `is_pay` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否付费', + `is_disciplined` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否合规', + `is_shelves` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否上架', + `submit_type` INT NOT NULL DEFAULT 0 COMMENT '提交类型', + `delete_archive` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否删除归档', + `charging` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否收费', + `button_grey` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '按钮是否置灰', + `app_gift` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否有礼包', + `free_days` INT NOT NULL DEFAULT 0 COMMENT '免费天数', + `pay_install_type` INT NOT NULL DEFAULT 0 COMMENT '付费安装类型', + `comment` JSON COMMENT '评论或注释数据', + `listed_at` DATETIME NOT NULL COMMENT '应用上架时间', + `release_countries` JSON COMMENT '应用发布的国家/地区列表', + `main_device_codes` JSON COMMENT '应用支持的主要设备类型', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX `idx_pkg_name` (`pkg_name`), + INDEX `idx_developer_name` (`developer_name`), + INDEX `idx_kind_name` (`kind_name`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应用基本信息表'; +``` + +#### 4.1.2 应用指标表 (app_metrics) + +```sql +CREATE TABLE `app_metrics` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + `app_id` VARCHAR(50) NOT NULL COMMENT '应用ID', + `pkg_name` VARCHAR(255) NOT NULL COMMENT '应用包名', + `version` VARCHAR(50) NOT NULL COMMENT '版本号', + `version_code` BIGINT NOT NULL COMMENT '版本代码', + `size_bytes` BIGINT NOT NULL COMMENT '应用大小(字节)', + `sha256` VARCHAR(64) NOT NULL COMMENT '安装包SHA256校验值', + `info_score` DECIMAL(3,1) NOT NULL COMMENT '信息评分', + `info_rate_count` BIGINT NOT NULL COMMENT '信息评分人数', + `download_count` BIGINT NOT NULL COMMENT '下载次数', + `price` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '价格', + `release_date` BIGINT NOT NULL COMMENT '发布时间(时间戳毫秒)', + `new_features` TEXT COMMENT '新功能描述', + `upgrade_msg` TEXT COMMENT '升级信息', + `target_sdk` VARCHAR(20) NOT NULL COMMENT '目标SDK版本', + `min_sdk` VARCHAR(20) NOT NULL COMMENT '最小SDK版本', + `compile_sdk_version` INT DEFAULT 0 COMMENT '编译SDK版本', + `min_hmos_api_level` INT DEFAULT 0 COMMENT '最小HarmonyOS API等级', + `api_release_type` VARCHAR(50) DEFAULT 'Release' COMMENT 'API发布类型', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + FOREIGN KEY (`app_id`) REFERENCES `app_info`(`app_id`) ON DELETE CASCADE, + FOREIGN KEY (`pkg_name`) REFERENCES `app_info`(`pkg_name`) ON DELETE CASCADE, + INDEX `idx_app_id` (`app_id`), + INDEX `idx_pkg_name` (`pkg_name`), + INDEX `idx_download_count` (`download_count`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应用指标表'; +``` + +#### 4.1.3 应用评分表 (app_rating) + +```sql +CREATE TABLE `app_rating` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + `app_id` VARCHAR(50) NOT NULL COMMENT '应用ID', + `pkg_name` VARCHAR(255) NOT NULL COMMENT '应用包名', + `average_rating` DECIMAL(3,2) NOT NULL COMMENT '平均评分', + `star_1_count` INT NOT NULL DEFAULT 0 COMMENT '1星评分数量', + `star_2_count` INT NOT NULL DEFAULT 0 COMMENT '2星评分数量', + `star_3_count` INT NOT NULL DEFAULT 0 COMMENT '3星评分数量', + `star_4_count` INT NOT NULL DEFAULT 0 COMMENT '4星评分数量', + `star_5_count` INT NOT NULL DEFAULT 0 COMMENT '5星评分数量', + `total_rating_count` INT NOT NULL DEFAULT 0 COMMENT '总评分数量', + `only_star_count` INT NOT NULL DEFAULT 0 COMMENT '仅星级数量', + `full_average_rating` VARCHAR(20) COMMENT '完整平均评分', + `source_type` VARCHAR(50) COMMENT '来源类型', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + FOREIGN KEY (`app_id`) REFERENCES `app_info`(`app_id`) ON DELETE CASCADE, + FOREIGN KEY (`pkg_name`) REFERENCES `app_info`(`pkg_name`) ON DELETE CASCADE, + INDEX `idx_app_id` (`app_id`), + INDEX `idx_pkg_name` (`pkg_name`), + INDEX `idx_average_rating` (`average_rating`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应用评分表'; +``` + +#### 4.1.4 原始数据历史表 (app_data_history) + +```sql +CREATE TABLE `app_data_history` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + `app_id` VARCHAR(50) NOT NULL COMMENT '应用ID', + `pkg_name` VARCHAR(255) NOT NULL COMMENT '应用包名', + `raw_json_data` JSON NOT NULL COMMENT '原始应用数据JSON', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + FOREIGN KEY (`app_id`) REFERENCES `app_info`(`app_id`) ON DELETE CASCADE, + FOREIGN KEY (`pkg_name`) REFERENCES `app_info`(`pkg_name`) ON DELETE CASCADE, + INDEX `idx_app_id` (`app_id`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='原始数据历史表'; +``` + +#### 4.1.5 评分历史表 (app_rating_history) + +```sql +CREATE TABLE `app_rating_history` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + `app_id` VARCHAR(50) NOT NULL COMMENT '应用ID', + `pkg_name` VARCHAR(255) NOT NULL COMMENT '应用包名', + `raw_json_rating` JSON NOT NULL COMMENT '原始评分数据JSON', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + FOREIGN KEY (`app_id`) REFERENCES `app_info`(`app_id`) ON DELETE CASCADE, + FOREIGN KEY (`pkg_name`) REFERENCES `app_info`(`pkg_name`) ON DELETE CASCADE, + INDEX `idx_app_id` (`app_id`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评分历史表'; +``` + +### 4.2 索引优化建议 + +1. **联合索引:** + - `(pkg_name, created_at)` - 用于按包名查询历史 + - `(developer_name, download_count)` - 用于开发者排行 + - `(kind_name, download_count)` - 用于分类排行 + +2. **全文索引:** + - `name`, `brief_desc` - 用于应用搜索 + +3. **分区策略:** + - 历史表按月分区,提高查询效率 + + +--- + +## 5. 后端开发 + +### 5.1 项目结构 + +``` +backend/ +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI 应用入口 +│ ├── config.py # 配置文件 +│ ├── database.py # 数据库连接 +│ ├── models/ # SQLAlchemy 模型 +│ │ ├── __init__.py +│ │ ├── app_info.py +│ │ ├── app_metrics.py +│ │ └── app_rating.py +│ ├── schemas/ # Pydantic 模型 +│ │ ├── __init__.py +│ │ ├── app.py +│ │ └── response.py +│ ├── api/ # API 路由 +│ │ ├── __init__.py +│ │ ├── apps.py +│ │ ├── rankings.py +│ │ ├── charts.py +│ │ └── submit.py +│ ├── crawler/ # 爬虫模块 +│ │ ├── __init__.py +│ │ ├── huawei_api.py # 华为API封装 +│ │ ├── token_manager.py # Token管理 +│ │ └── data_processor.py # 数据处理 +│ ├── scheduler/ # 调度模块 +│ │ ├── __init__.py +│ │ └── tasks.py +│ └── utils/ # 工具函数 +│ ├── __init__.py +│ └── helpers.py +├── requirements.txt +├── .env.example +└── README.md +``` + +### 5.2 核心代码实现 + +#### 5.2.1 配置文件 (config.py) + +```python +from pydantic_settings import BaseSettings +from typing import List + +class Settings(BaseSettings): + # 数据库配置 + MYSQL_HOST: str = "localhost" + MYSQL_PORT: int = 3306 + MYSQL_USER: str = "root" + MYSQL_PASSWORD: str = "password" + MYSQL_DATABASE: str = "huawei_market" + + # 华为API配置 + HUAWEI_API_BASE_URL: str = "https://web-drcn.hispace.dbankcloud.com/edge" + HUAWEI_LOCALE: str = "zh_CN" + + # 爬虫配置 + CRAWLER_INTERVAL: int = 1800 # 同步间隔(秒) + CRAWLER_BATCH_SIZE: int = 100 # 批量处理大小 + CRAWLER_TIMEOUT: int = 30 # 请求超时(秒) + + # API配置 + API_PREFIX: str = "/api" + API_TITLE: str = "华为应用市场数据API" + API_VERSION: str = "1.0.0" + + # 其他配置 + DEBUG: bool = False + CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"] + + @property + def database_url(self) -> str: + return f"mysql+aiomysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}" + + class Config: + env_file = ".env" + +settings = Settings() +``` + +#### 5.2.2 数据库连接 (database.py) + +```python +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.config import settings + +# 创建异步引擎 +engine = create_async_engine( + settings.database_url, + echo=settings.DEBUG, + pool_size=10, + max_overflow=20, + pool_pre_ping=True +) + +# 创建异步会话工厂 +AsyncSessionLocal = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False +) + +# 创建基类 +Base = declarative_base() + +# 依赖注入 +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() +``` + +#### 5.2.3 数据模型 (models/app_info.py) + +```python +from sqlalchemy import Column, String, Integer, Text, DateTime, Boolean, JSON, DECIMAL, BigInteger +from sqlalchemy.sql import func +from app.database import Base + +class AppInfo(Base): + __tablename__ = "app_info" + + app_id = Column(String(50), primary_key=True, comment="应用唯一ID") + alliance_app_id = Column(String(50), comment="联盟应用ID") + name = Column(String(255), nullable=False, comment="应用名称") + pkg_name = Column(String(255), nullable=False, unique=True, index=True, comment="应用包名") + dev_id = Column(String(50), nullable=False, comment="开发者ID") + developer_name = Column(String(255), nullable=False, index=True, comment="开发者名称") + dev_en_name = Column(String(255), comment="开发者英文名称") + supplier = Column(String(255), comment="供应商名称") + kind_id = Column(Integer, nullable=False, comment="应用分类ID") + kind_name = Column(String(100), nullable=False, index=True, comment="应用分类名称") + tag_name = Column(String(255), comment="标签名称") + kind_type_id = Column(Integer, nullable=False, comment="类型ID") + kind_type_name = Column(String(100), nullable=False, comment="类型名称") + icon_url = Column(Text, nullable=False, comment="应用图标URL") + brief_desc = Column(Text, nullable=False, comment="简短描述") + description = Column(Text, nullable=False, comment="应用详细描述") + privacy_url = Column(Text, nullable=False, comment="隐私政策链接") + + # 布尔字段 + iap = Column(Boolean, default=False, comment="是否含应用内购买") + hms = Column(Boolean, default=False, comment="是否依赖HMS") + is_pay = Column(Boolean, default=False, comment="是否付费") + is_shelves = Column(Boolean, default=True, comment="是否上架") + + # JSON字段 + comment = Column(JSON, comment="评论或注释数据") + release_countries = Column(JSON, comment="应用发布的国家/地区列表") + main_device_codes = Column(JSON, comment="应用支持的主要设备类型") + + # 时间字段 + listed_at = Column(DateTime, nullable=False, comment="应用上架时间") + created_at = Column(DateTime, nullable=False, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now(), comment="更新时间") +``` + +#### 5.2.4 华为API封装 (crawler/huawei_api.py) + +```python +import httpx +import asyncio +import json +from typing import Optional, Dict, Any +from app.config import settings +from app.crawler.token_manager import TokenManager + +class HuaweiAPI: + def __init__(self): + self.base_url = settings.HUAWEI_API_BASE_URL + self.locale = settings.HUAWEI_LOCALE + self.token_manager = TokenManager() + self.client = httpx.AsyncClient(timeout=settings.CRAWLER_TIMEOUT) + + async def get_app_info(self, pkg_name: Optional[str] = None, app_id: Optional[str] = None) -> Dict[str, Any]: + """获取应用基本信息""" + if not pkg_name and not app_id: + raise ValueError("必须提供 pkg_name 或 app_id") + + # 获取token + tokens = await self.token_manager.get_token() + + # 构建请求 + url = f"{self.base_url}/webedge/appinfo" + headers = { + "Content-Type": "application/json", + "User-Agent": "HuaweiMarketCrawler/1.0", + "interface-code": tokens["interface_code"], + "identity-id": tokens["identity_id"] + } + + body = {"locale": self.locale} + if pkg_name: + body["pkgName"] = pkg_name + else: + body["appId"] = app_id + + # 发送请求 + response = await self.client.post(url, headers=headers, json=body) + response.raise_for_status() + + data = response.json() + + # 数据清洗 + return self._clean_data(data) + + async def get_app_rating(self, app_id: str) -> Optional[Dict[str, Any]]: + """获取应用评分详情""" + # 跳过元服务 + if app_id.startswith("com.atomicservice"): + return None + + tokens = await self.token_manager.get_token() + + url = f"{self.base_url}/harmony/page-detail" + headers = { + "Content-Type": "application/json", + "User-Agent": "HuaweiMarketCrawler/1.0", + "Interface-Code": tokens["interface_code"], + "identity-id": tokens["identity_id"] + } + + body = { + "pageId": f"webAgAppDetail|{app_id}", + "pageNum": 1, + "pageSize": 100, + "zone": "" + } + + try: + response = await self.client.post(url, headers=headers, json=body) + response.raise_for_status() + data = response.json() + + # 解析评分数据 + layouts = data["pages"][0]["data"]["cardlist"]["layoutData"] + comment_cards = [l for l in layouts if l.get("type") == "fl.card.comment"] + + if not comment_cards: + return None + + star_info_str = comment_cards[0]["data"][0]["starInfo"] + return json.loads(star_info_str) + + except Exception as e: + print(f"获取评分失败: {e}") + return None + + def _clean_data(self, data: Dict[str, Any]) -> Dict[str, Any]: + """清洗数据""" + # 移除 \0 字符 + for key, value in data.items(): + if isinstance(value, str): + data[key] = value.replace('\x00', '') + + # 移除 AG-TraceId + data.pop('AG-TraceId', None) + + # 验证 appId 长度 + if len(data.get('appId', '')) < 15: + raise ValueError("appId长度小于15,可能是安卓应用") + + return data + + async def close(self): + """关闭客户端""" + await self.client.aclose() +``` + + +#### 5.2.5 Token管理器 (crawler/token_manager.py) + +```python +import asyncio +from datetime import datetime, timedelta +from typing import Dict +from playwright.async_api import async_playwright + +class TokenManager: + def __init__(self): + self.tokens: Dict[str, str] = {} + self.token_expires_at: datetime = datetime.now() + self.lock = asyncio.Lock() + + async def get_token(self) -> Dict[str, str]: + """获取有效的token""" + async with self.lock: + if datetime.now() >= self.token_expires_at or not self.tokens: + await self._refresh_token() + return self.tokens + + async def _refresh_token(self): + """刷新token""" + print("正在刷新token...") + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + page = await browser.new_page() + + # 拦截请求获取token + tokens = {} + + async def handle_request(request): + headers = request.headers + if 'interface-code' in headers: + tokens['interface_code'] = headers['interface-code'] + tokens['identity_id'] = headers['identity-id'] + + page.on('request', handle_request) + + # 访问华为应用市场 + await page.goto('https://appgallery.huawei.com/', wait_until='networkidle') + await page.wait_for_timeout(3000) + + await browser.close() + + if tokens: + self.tokens = tokens + # token有效期设为10分钟 + self.token_expires_at = datetime.now() + timedelta(minutes=10) + print(f"Token刷新成功,有效期至: {self.token_expires_at}") + else: + raise Exception("无法获取token") +``` + +#### 5.2.6 数据处理器 (crawler/data_processor.py) + +```python +from typing import Dict, Any, Optional, Tuple +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.models.app_info import AppInfo +from app.models.app_metrics import AppMetrics +from app.models.app_rating import AppRating +from app.models.app_data_history import AppDataHistory +from app.models.app_rating_history import AppRatingHistory + +class DataProcessor: + def __init__(self, db: AsyncSession): + self.db = db + + async def save_app_data( + self, + app_data: Dict[str, Any], + rating_data: Optional[Dict[str, Any]] = None, + comment: Optional[Dict[str, Any]] = None + ) -> Tuple[bool, bool, bool]: + """ + 保存应用数据 + 返回: (是否插入新应用信息, 是否插入新指标, 是否插入新评分) + """ + app_id = app_data['appId'] + pkg_name = app_data['pkgName'] + + # 检查应用是否存在 + result = await self.db.execute( + select(AppInfo).where(AppInfo.app_id == app_id) + ) + existing_app = result.scalar_one_or_none() + + # 保存应用基本信息 + info_inserted = False + if not existing_app or await self._is_info_changed(existing_app, app_data): + await self._save_app_info(app_data, comment) + info_inserted = True + + # 保存应用指标 + metric_inserted = False + if await self._should_save_metric(app_id, app_data): + await self._save_app_metric(app_data) + metric_inserted = True + + # 保存评分数据 + rating_inserted = False + if rating_data and await self._should_save_rating(app_id, rating_data): + await self._save_app_rating(app_id, pkg_name, rating_data) + rating_inserted = True + + # 保存原始数据历史 + if info_inserted or metric_inserted: + await self._save_data_history(app_id, pkg_name, app_data) + + if rating_inserted: + await self._save_rating_history(app_id, pkg_name, rating_data) + + await self.db.commit() + + return info_inserted, metric_inserted, rating_inserted + + async def _save_app_info(self, data: Dict[str, Any], comment: Optional[Dict] = None): + """保存应用基本信息""" + app_info = AppInfo( + app_id=data['appId'], + alliance_app_id=data.get('allianceAppId', ''), + name=data['name'], + pkg_name=data['pkgName'], + dev_id=data['devId'], + developer_name=data['developerName'], + dev_en_name=data.get('devEnName', ''), + supplier=data.get('supplier', ''), + kind_id=int(data['kindId']), + kind_name=data['kindName'], + tag_name=data.get('tagName'), + kind_type_id=int(data['kindTypeId']), + kind_type_name=data['kindTypeName'], + icon_url=data['icon'], + brief_desc=data['briefDes'], + description=data['description'], + privacy_url=data['privacyUrl'], + iap=bool(data.get('iap', 0)), + hms=bool(data.get('hms', 0)), + is_pay=data.get('isPay') == '1', + is_shelves=bool(data.get('isShelves', 1)), + comment=comment, + release_countries=data.get('releaseCountries', []), + main_device_codes=data.get('mainDeviceCodes', []), + listed_at=datetime.fromtimestamp(data.get('releaseDate', 0) / 1000) + ) + + # 使用 merge 实现 upsert + self.db.add(app_info) + + async def _save_app_metric(self, data: Dict[str, Any]): + """保存应用指标""" + # 清洗下载量数据 + download_count = self._parse_download_count(data.get('downCount', '0')) + + metric = AppMetrics( + app_id=data['appId'], + pkg_name=data['pkgName'], + version=data['version'], + version_code=int(data['versionCode']), + size_bytes=int(data['size']), + sha256=data.get('sha256', ''), + info_score=float(data.get('hot', '0.0')), + info_rate_count=int(data.get('rateNum', '0')), + download_count=download_count, + price=float(data.get('price', '0')), + release_date=int(data.get('releaseDate', 0)), + new_features=data.get('newFeatures', ''), + upgrade_msg=data.get('upgradeMsg', ''), + target_sdk=data.get('targetSdk', ''), + min_sdk=data.get('minsdk', ''), + compile_sdk_version=int(data.get('compileSdkVersion', 0)), + min_hmos_api_level=int(data.get('minHmosApiLevel', 0)), + api_release_type=data.get('apiReleaseType', 'Release') + ) + + self.db.add(metric) + + async def _save_app_rating(self, app_id: str, pkg_name: str, data: Dict[str, Any]): + """保存应用评分""" + rating = AppRating( + app_id=app_id, + pkg_name=pkg_name, + average_rating=float(data['averageRating']), + star_1_count=int(data['oneStarRatingCount']), + star_2_count=int(data['twoStarRatingCount']), + star_3_count=int(data['threeStarRatingCount']), + star_4_count=int(data['fourStarRatingCount']), + star_5_count=int(data['fiveStarRatingCount']), + total_rating_count=int(data['totalStarRatingCount']), + only_star_count=int(data.get('onlyStarCount', 0)), + full_average_rating=data.get('fullAverageRating', ''), + source_type=data.get('sourceType', '') + ) + + self.db.add(rating) + + def _parse_download_count(self, count_str: str) -> int: + """解析下载量字符串""" + # 移除 + 号和其他非数字字符 + count_str = count_str.replace('+', '').replace(',', '') + try: + return int(count_str) + except ValueError: + return 0 + + async def _is_info_changed(self, existing: AppInfo, new_data: Dict) -> bool: + """检查应用信息是否变化""" + return ( + existing.name != new_data['name'] or + existing.version != new_data.get('version', '') or + existing.description != new_data.get('description', '') + ) + + async def _should_save_metric(self, app_id: str, data: Dict) -> bool: + """判断是否需要保存新的指标数据""" + # 查询最新的指标 + result = await self.db.execute( + select(AppMetrics) + .where(AppMetrics.app_id == app_id) + .order_by(AppMetrics.created_at.desc()) + .limit(1) + ) + latest_metric = result.scalar_one_or_none() + + if not latest_metric: + return True + + # 比较关键字段 + return ( + latest_metric.version != data['version'] or + latest_metric.download_count != self._parse_download_count(data.get('downCount', '0')) + ) + + async def _should_save_rating(self, app_id: str, data: Dict) -> bool: + """判断是否需要保存新的评分数据""" + result = await self.db.execute( + select(AppRating) + .where(AppRating.app_id == app_id) + .order_by(AppRating.created_at.desc()) + .limit(1) + ) + latest_rating = result.scalar_one_or_none() + + if not latest_rating: + return True + + return ( + float(latest_rating.average_rating) != float(data['averageRating']) or + latest_rating.total_rating_count != int(data['totalStarRatingCount']) + ) +``` + + +#### 5.2.7 API路由 (api/apps.py) + +```python +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, or_ +from typing import Optional, List +from app.database import get_db +from app.models.app_info import AppInfo +from app.models.app_metrics import AppMetrics +from app.models.app_rating import AppRating +from app.schemas.response import ApiResponse +from app.crawler.huawei_api import HuaweiAPI +from app.crawler.data_processor import DataProcessor + +router = APIRouter(prefix="/apps", tags=["应用"]) + +@router.get("/pkg_name/{pkg_name}") +async def get_app_by_pkg_name( + pkg_name: str, + db: AsyncSession = Depends(get_db) +): + """按包名查询应用""" + # 尝试从API获取最新数据 + api = HuaweiAPI() + try: + app_data = await api.get_app_info(pkg_name=pkg_name) + rating_data = await api.get_app_rating(app_data['appId']) + + # 保存到数据库 + processor = DataProcessor(db) + new_info, new_metric, new_rating = await processor.save_app_data( + app_data, rating_data + ) + + # 查询完整数据 + result = await db.execute( + select(AppInfo, AppMetrics, AppRating) + .join(AppMetrics, AppInfo.app_id == AppMetrics.app_id) + .outerjoin(AppRating, AppInfo.app_id == AppRating.app_id) + .where(AppInfo.pkg_name == pkg_name) + .order_by(AppMetrics.created_at.desc()) + .limit(1) + ) + row = result.first() + + return ApiResponse( + success=True, + data={ + "info": row[0].__dict__ if row else None, + "metric": row[1].__dict__ if row and len(row) > 1 else None, + "rating": row[2].__dict__ if row and len(row) > 2 else None, + "new_info": new_info, + "new_metric": new_metric, + "new_rating": new_rating, + "get_data": True + } + ) + + except Exception as e: + # 回退到数据库数据 + result = await db.execute( + select(AppInfo, AppMetrics, AppRating) + .join(AppMetrics, AppInfo.app_id == AppMetrics.app_id) + .outerjoin(AppRating, AppInfo.app_id == AppRating.app_id) + .where(AppInfo.pkg_name == pkg_name) + .order_by(AppMetrics.created_at.desc()) + .limit(1) + ) + row = result.first() + + if not row: + raise HTTPException(status_code=404, detail=f"应用 {pkg_name} 不存在") + + return ApiResponse( + success=True, + data={ + "info": row[0].__dict__, + "metric": row[1].__dict__ if len(row) > 1 else None, + "rating": row[2].__dict__ if len(row) > 2 else None, + "get_data": False, + "error": str(e) + } + ) + finally: + await api.close() + +@router.get("/list/{page}") +async def get_app_list( + page: int = 1, + page_size: int = Query(100, le=500), + detail: bool = True, + sort: Optional[str] = None, + desc: bool = True, + search_key: Optional[str] = None, + search_value: Optional[str] = None, + search_exact: bool = False, + db: AsyncSession = Depends(get_db) +): + """分页获取应用列表""" + # 构建基础查询 + if detail: + query = select(AppInfo, AppMetrics, AppRating).join( + AppMetrics, AppInfo.app_id == AppMetrics.app_id + ).outerjoin( + AppRating, AppInfo.app_id == AppRating.app_id + ) + else: + query = select(AppInfo) + + # 搜索过滤 + if search_key and search_value: + if search_exact: + query = query.where(getattr(AppInfo, search_key) == search_value) + else: + query = query.where(getattr(AppInfo, search_key).like(f"%{search_value}%")) + + # 排序 + if sort: + order_column = getattr(AppMetrics if hasattr(AppMetrics, sort) else AppInfo, sort) + query = query.order_by(order_column.desc() if desc else order_column.asc()) + else: + query = query.order_by(AppMetrics.download_count.desc()) + + # 计算总数 + count_query = select(func.count()).select_from(AppInfo) + if search_key and search_value: + if search_exact: + count_query = count_query.where(getattr(AppInfo, search_key) == search_value) + else: + count_query = count_query.where(getattr(AppInfo, search_key).like(f"%{search_value}%")) + + total_result = await db.execute(count_query) + total_count = total_result.scalar() + + # 分页 + offset = (page - 1) * page_size + query = query.offset(offset).limit(page_size) + + result = await db.execute(query) + rows = result.all() + + # 格式化数据 + data = [] + for row in rows: + if detail: + data.append({ + "info": row[0].__dict__, + "metric": row[1].__dict__ if len(row) > 1 else None, + "rating": row[2].__dict__ if len(row) > 2 else None + }) + else: + data.append(row[0].__dict__) + + return ApiResponse( + success=True, + data=data, + total=total_count, + limit=page_size + ) + +@router.get("/metrics/{pkg_name}") +async def get_app_metrics_history( + pkg_name: str, + db: AsyncSession = Depends(get_db) +): + """获取应用指标历史""" + result = await db.execute( + select(AppMetrics) + .where(AppMetrics.pkg_name == pkg_name) + .order_by(AppMetrics.created_at.desc()) + ) + metrics = result.scalars().all() + + return ApiResponse( + success=True, + data=[m.__dict__ for m in metrics] + ) +``` + +#### 5.2.8 排行榜API (api/rankings.py) + +```python +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from datetime import datetime, timedelta +from app.database import get_db +from app.models.app_info import AppInfo +from app.models.app_metrics import AppMetrics +from app.models.app_rating import AppRating +from app.schemas.response import ApiResponse + +router = APIRouter(prefix="/rankings", tags=["排行榜"]) + +@router.get("/top-downloads") +async def get_top_downloads( + limit: int = Query(10, le=100), + exclude_pattern: str = Query(None), + db: AsyncSession = Depends(get_db) +): + """下载量排行榜""" + # 子查询:获取每个应用的最新指标 + subquery = ( + select( + AppMetrics.app_id, + func.max(AppMetrics.created_at).label('max_created_at') + ) + .group_by(AppMetrics.app_id) + .subquery() + ) + + # 主查询 + query = ( + select(AppInfo, AppMetrics) + .join(AppMetrics, AppInfo.app_id == AppMetrics.app_id) + .join( + subquery, + and_( + AppMetrics.app_id == subquery.c.app_id, + AppMetrics.created_at == subquery.c.max_created_at + ) + ) + .order_by(AppMetrics.download_count.desc()) + .limit(limit) + ) + + # 排除模式 + if exclude_pattern: + query = query.where(~AppInfo.pkg_name.like(f"%{exclude_pattern}%")) + + result = await db.execute(query) + rows = result.all() + + data = [ + { + "app_id": row[0].app_id, + "name": row[0].name, + "pkg_name": row[0].pkg_name, + "developer_name": row[0].developer_name, + "icon_url": row[0].icon_url, + "download_count": row[1].download_count, + "version": row[1].version + } + for row in rows + ] + + return ApiResponse(success=True, data=data, limit=limit) + +@router.get("/ratings") +async def get_top_ratings( + limit: int = Query(10, le=100), + db: AsyncSession = Depends(get_db) +): + """评分排行榜""" + subquery = ( + select( + AppRating.app_id, + func.max(AppRating.created_at).label('max_created_at') + ) + .group_by(AppRating.app_id) + .subquery() + ) + + query = ( + select(AppInfo, AppRating) + .join(AppRating, AppInfo.app_id == AppRating.app_id) + .join( + subquery, + and_( + AppRating.app_id == subquery.c.app_id, + AppRating.created_at == subquery.c.max_created_at + ) + ) + .where(AppRating.total_rating_count >= 100) # 至少100个评分 + .order_by(AppRating.average_rating.desc()) + .limit(limit) + ) + + result = await db.execute(query) + rows = result.all() + + data = [ + { + "app_id": row[0].app_id, + "name": row[0].name, + "pkg_name": row[0].pkg_name, + "developer_name": row[0].developer_name, + "icon_url": row[0].icon_url, + "average_rating": float(row[1].average_rating), + "total_rating_count": row[1].total_rating_count + } + for row in rows + ] + + return ApiResponse(success=True, data=data, limit=limit) + +@router.get("/developers") +async def get_top_developers( + limit: int = Query(10, le=100), + db: AsyncSession = Depends(get_db) +): + """开发者排行榜(按应用数量)""" + query = ( + select( + AppInfo.developer_name, + func.count(AppInfo.app_id).label('app_count'), + func.sum(AppMetrics.download_count).label('total_downloads') + ) + .join(AppMetrics, AppInfo.app_id == AppMetrics.app_id) + .group_by(AppInfo.developer_name) + .order_by(func.count(AppInfo.app_id).desc()) + .limit(limit) + ) + + result = await db.execute(query) + rows = result.all() + + data = [ + { + "developer_name": row[0], + "app_count": row[1], + "total_downloads": row[2] or 0 + } + for row in rows + ] + + return ApiResponse(success=True, data=data, limit=limit) +``` + + +#### 5.2.9 定时任务 (scheduler/tasks.py) + +```python +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.interval import IntervalTrigger +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import AsyncSessionLocal +from app.config import settings +from app.crawler.huawei_api import HuaweiAPI +from app.crawler.data_processor import DataProcessor +import asyncio +import random + +class CrawlerScheduler: + def __init__(self): + self.scheduler = AsyncIOScheduler() + self.is_running = False + + def start(self): + """启动调度器""" + # 添加定时任务 + self.scheduler.add_job( + self.sync_all_apps, + trigger=IntervalTrigger(seconds=settings.CRAWLER_INTERVAL), + id='sync_all_apps', + name='同步所有应用', + replace_existing=True + ) + + self.scheduler.start() + print(f"调度器已启动,同步间隔: {settings.CRAWLER_INTERVAL}秒") + + def stop(self): + """停止调度器""" + self.scheduler.shutdown() + print("调度器已停止") + + async def sync_all_apps(self): + """同步所有应用""" + if self.is_running: + print("上一次同步尚未完成,跳过本次同步") + return + + self.is_running = True + print(f"开始同步所有应用 - {datetime.now()}") + + try: + async with AsyncSessionLocal() as db: + # 获取所有包名 + from sqlalchemy import select + from app.models.app_info import AppInfo + + result = await db.execute(select(AppInfo.pkg_name)) + pkg_names = [row[0] for row in result.all()] + + # 随机打乱顺序 + random.shuffle(pkg_names) + + print(f"共需同步 {len(pkg_names)} 个应用") + + # 批量处理 + api = HuaweiAPI() + processor = DataProcessor(db) + + total_processed = 0 + total_inserted = 0 + total_failed = 0 + + for i in range(0, len(pkg_names), settings.CRAWLER_BATCH_SIZE): + batch = pkg_names[i:i + settings.CRAWLER_BATCH_SIZE] + + # 并发处理批次 + tasks = [ + self._sync_single_app(api, processor, pkg_name) + for pkg_name in batch + ] + + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 统计结果 + for result in results: + total_processed += 1 + if isinstance(result, Exception): + total_failed += 1 + elif result: + total_inserted += 1 + + print(f"已处理 {total_processed}/{len(pkg_names)} 个应用") + + # 批次间延迟 + await asyncio.sleep(0.5) + + await api.close() + + print(f"同步完成 - 处理: {total_processed}, 更新: {total_inserted}, 失败: {total_failed}") + + except Exception as e: + print(f"同步失败: {e}") + + finally: + self.is_running = False + + async def _sync_single_app( + self, + api: HuaweiAPI, + processor: DataProcessor, + pkg_name: str + ) -> bool: + """同步单个应用""" + try: + # 获取应用数据 + app_data = await api.get_app_info(pkg_name=pkg_name) + rating_data = await api.get_app_rating(app_data['appId']) + + # 保存数据 + new_info, new_metric, new_rating = await processor.save_app_data( + app_data, rating_data + ) + + return new_info or new_metric or new_rating + + except Exception as e: + print(f"同步 {pkg_name} 失败: {e}") + return False + +# 全局调度器实例 +scheduler = CrawlerScheduler() +``` + +#### 5.2.10 主应用 (main.py) + +```python +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +from app.config import settings +from app.api import apps, rankings, charts, submit +from app.scheduler.tasks import scheduler + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + # 启动时 + print("应用启动中...") + scheduler.start() + yield + # 关闭时 + print("应用关闭中...") + scheduler.stop() + +# 创建FastAPI应用 +app = FastAPI( + title=settings.API_TITLE, + version=settings.API_VERSION, + lifespan=lifespan +) + +# CORS中间件 +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 注册路由 +app.include_router(apps.router, prefix=settings.API_PREFIX) +app.include_router(rankings.router, prefix=settings.API_PREFIX) +app.include_router(charts.router, prefix=settings.API_PREFIX) +app.include_router(submit.router, prefix=settings.API_PREFIX) + +@app.get("/") +async def root(): + return {"message": "华为应用市场数据API", "version": settings.API_VERSION} + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=settings.DEBUG + ) +``` + +### 5.3 依赖文件 (requirements.txt) + +```txt +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +aiomysql==0.2.0 +pydantic==2.5.3 +pydantic-settings==2.1.0 +httpx==0.26.0 +playwright==1.41.0 +apscheduler==3.10.4 +python-dotenv==1.0.0 +python-multipart==0.0.6 +``` + +### 5.4 环境配置 (.env.example) + +```env +# 数据库配置 +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=your_password +MYSQL_DATABASE=huawei_market + +# 华为API配置 +HUAWEI_API_BASE_URL=https://web-drcn.hispace.dbankcloud.com/edge +HUAWEI_LOCALE=zh_CN + +# 爬虫配置 +CRAWLER_INTERVAL=1800 +CRAWLER_BATCH_SIZE=100 +CRAWLER_TIMEOUT=30 + +# API配置 +API_PREFIX=/api +API_TITLE=华为应用市场数据API +API_VERSION=1.0.0 + +# 其他配置 +DEBUG=False +CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] +``` + + +--- + +## 6. 前端开发 + +### 6.1 项目结构 + +``` +frontend/ +├── public/ +│ └── favicon.ico +├── src/ +│ ├── assets/ # 静态资源 +│ │ ├── styles/ +│ │ │ └── main.css +│ │ └── images/ +│ ├── components/ # 组件 +│ │ ├── AppCard.vue +│ │ ├── AppTable.vue +│ │ ├── ChartCard.vue +│ │ ├── StatCard.vue +│ │ └── SearchBar.vue +│ ├── views/ # 页面 +│ │ ├── Dashboard.vue +│ │ ├── AppDetail.vue +│ │ └── Rankings.vue +│ ├── api/ # API封装 +│ │ ├── index.ts +│ │ └── apps.ts +│ ├── stores/ # 状态管理 +│ │ └── app.ts +│ ├── types/ # 类型定义 +│ │ └── app.ts +│ ├── utils/ # 工具函数 +│ │ └── format.ts +│ ├── router/ # 路由 +│ │ └── index.ts +│ ├── App.vue +│ └── main.ts +├── index.html +├── package.json +├── tsconfig.json +├── vite.config.ts +└── README.md +``` + +### 6.2 核心代码实现 + +#### 6.2.1 类型定义 (types/app.ts) + +```typescript +export interface AppInfo { + app_id: string + name: string + pkg_name: string + developer_name: string + dev_en_name?: string + kind_name: string + kind_type_name: string + icon_url: string + brief_desc: string + description: string + privacy_url: string + iap: boolean + is_pay: boolean + listed_at: string + created_at: string +} + +export interface AppMetric { + id: number + app_id: string + pkg_name: string + version: string + version_code: number + size_bytes: number + download_count: number + info_score: number + info_rate_count: number + price: number + release_date: number + target_sdk: string + min_sdk: string + created_at: string +} + +export interface AppRating { + id: number + app_id: string + average_rating: number + star_1_count: number + star_2_count: number + star_3_count: number + star_4_count: number + star_5_count: number + total_rating_count: number + created_at: string +} + +export interface FullAppInfo { + info: AppInfo + metric: AppMetric + rating?: AppRating +} + +export interface ApiResponse { + success: boolean + data: T + total?: number + limit?: number + timestamp: string +} + +export interface MarketStats { + app_count: { + total: number + apps: number + atomic_services: number + } + developer_count: number +} + +export interface RankingItem { + app_id: string + name: string + pkg_name: string + developer_name: string + icon_url: string + download_count?: number + average_rating?: number + total_rating_count?: number +} +``` + +#### 6.2.2 API封装 (api/apps.ts) + +```typescript +import axios from 'axios' +import type { ApiResponse, FullAppInfo, MarketStats, RankingItem } from '@/types/app' + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api', + timeout: 30000 +}) + +// 请求拦截器 +api.interceptors.request.use( + config => { + // 可以在这里添加token等 + return config + }, + error => { + return Promise.reject(error) + } +) + +// 响应拦截器 +api.interceptors.response.use( + response => { + return response.data + }, + error => { + console.error('API Error:', error) + return Promise.reject(error) + } +) + +export const appsApi = { + // 获取市场统计信息 + getMarketInfo: () => + api.get>('/market_info'), + + // 按包名查询应用 + getAppByPkgName: (pkgName: string) => + api.get>(`/apps/pkg_name/${pkgName}`), + + // 按应用ID查询 + getAppById: (appId: string) => + api.get>(`/apps/app_id/${appId}`), + + // 获取应用列表 + getAppList: (params: { + page: number + page_size?: number + detail?: boolean + sort?: string + desc?: boolean + search_key?: string + search_value?: string + search_exact?: boolean + }) => + api.get>(`/apps/list/${params.page}`, { params }), + + // 获取应用指标历史 + getAppMetrics: (pkgName: string) => + api.get>(`/apps/metrics/${pkgName}`), + + // 获取下载排行 + getTopDownloads: (params?: { limit?: number; exclude_pattern?: string }) => + api.get>('/rankings/top-downloads', { params }), + + // 获取评分排行 + getTopRatings: (params?: { limit?: number }) => + api.get>('/rankings/ratings', { params }), + + // 获取开发者排行 + getTopDevelopers: (params?: { limit?: number }) => + api.get>('/rankings/developers', { params }), + + // 获取评分分布 + getRatingDistribution: () => + api.get>>('/charts/rating'), + + // 获取SDK分布 + getMinSdkDistribution: () => + api.get>>('/charts/min_sdk'), + + getTargetSdkDistribution: () => + api.get>>('/charts/target_sdk'), + + // 投稿应用 + submitApp: (data: { + pkg_name?: string + app_id?: string + comment?: any + }) => + api.post>('/submit', data) +} + +export default api +``` + +#### 6.2.3 状态管理 (stores/app.ts) + +```typescript +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { appsApi } from '@/api/apps' +import type { MarketStats, FullAppInfo } from '@/types/app' + +export const useAppStore = defineStore('app', () => { + // 状态 + const marketStats = ref(null) + const appList = ref([]) + const currentPage = ref(1) + const pageSize = ref(100) + const totalCount = ref(0) + const loading = ref(false) + + // 计算属性 + const totalPages = computed(() => Math.ceil(totalCount.value / pageSize.value)) + + // 方法 + const fetchMarketStats = async () => { + try { + const response = await appsApi.getMarketInfo() + if (response.success) { + marketStats.value = response.data + } + } catch (error) { + console.error('获取市场统计失败:', error) + } + } + + const fetchAppList = async (params: { + page?: number + page_size?: number + sort?: string + desc?: boolean + search_key?: string + search_value?: string + search_exact?: boolean + } = {}) => { + loading.value = true + try { + const response = await appsApi.getAppList({ + page: params.page || currentPage.value, + page_size: params.page_size || pageSize.value, + detail: true, + ...params + }) + + if (response.success) { + appList.value = response.data + totalCount.value = response.total || 0 + currentPage.value = params.page || currentPage.value + } + } catch (error) { + console.error('获取应用列表失败:', error) + } finally { + loading.value = false + } + } + + const searchApps = async (searchKey: string, searchValue: string, exact: boolean = false) => { + await fetchAppList({ + page: 1, + search_key: searchKey, + search_value: searchValue, + search_exact: exact + }) + } + + return { + marketStats, + appList, + currentPage, + pageSize, + totalCount, + totalPages, + loading, + fetchMarketStats, + fetchAppList, + searchApps + } +}) +``` + +#### 6.2.4 工具函数 (utils/format.ts) + +```typescript +/** + * 格式化文件大小 + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] +} + +/** + * 格式化下载量 + */ +export function formatDownloadCount(count: number): string { + if (count >= 100000000) { + return (count / 100000000).toFixed(1) + '亿' + } else if (count >= 10000) { + return (count / 10000).toFixed(1) + '万' + } + return count.toString() +} + +/** + * 格式化日期 + */ +export function formatDate(date: string | number): string { + const d = new Date(date) + return d.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) +} + +/** + * 格式化评分 + */ +export function formatRating(rating: number): string { + return rating.toFixed(1) +} + +/** + * 获取星级数组 + */ +export function getStarArray(rating: number): boolean[] { + const fullStars = Math.floor(rating) + const hasHalfStar = rating % 1 >= 0.5 + const stars: boolean[] = [] + + for (let i = 0; i < 5; i++) { + stars.push(i < fullStars || (i === fullStars && hasHalfStar)) + } + + return stars +} +``` + + +--- + +## 附录A:如何获取应用包名 + +### A.1 从华为应用市场网页获取 + +#### 方法1:从URL中提取 + +访问华为应用市场应用详情页,URL格式如下: + +``` +https://appgallery.huawei.com/app/C1164531384803416384 +``` + +或者: + +``` +https://appgallery.huawei.com/#/app/C1164531384803416384 +``` + +**注意:** URL中的是 `app_id`,不是包名。需要进一步获取包名。 + +#### 方法2:从网页源码中提取 + +1. 打开应用详情页 +2. 右键 -> 查看网页源代码 +3. 搜索 `"pkgName"` 或 `"packageName"` +4. 找到类似这样的内容: + +```json +{ + "pkgName": "com.huawei.hmsapp.appgallery", + "appId": "C1164531384803416384", + ... +} +``` + +#### 方法3:使用浏览器开发者工具 + +1. 打开应用详情页 +2. 按 F12 打开开发者工具 +3. 切换到 Network(网络)标签 +4. 刷新页面 +5. 筛选 XHR 请求,找到 `appinfo` 相关的请求 +6. 查看请求的 Response,找到 `pkgName` 字段 + +**示例截图说明:** +``` +Network -> XHR -> appinfo +Response: +{ + "pkgName": "com.huawei.hmsapp.appgallery", + "name": "应用市场", + ... +} +``` + +### A.2 从安卓设备获取 + +#### 方法1:使用 ADB 命令 + +如果你有安卓设备或模拟器: + +```bash +# 列出所有已安装应用的包名 +adb shell pm list packages + +# 列出第三方应用 +adb shell pm list packages -3 + +# 搜索特定应用(例如包含 huawei 的) +adb shell pm list packages | grep huawei + +# 获取当前运行应用的包名 +adb shell dumpsys window | grep mCurrentFocus +``` + +**输出示例:** +``` +package:com.huawei.hmsapp.appgallery +package:com.huawei.browser +package:com.huawei.music +``` + +#### 方法2:使用应用信息查看器 + +在安卓设备上安装 "应用信息查看器" 类的应用,例如: +- **Package Name Viewer** +- **App Inspector** +- **Dev Tools** + +这些应用可以直接显示已安装应用的包名。 + +### A.3 批量获取包名的方法 + +#### 方法1:爬取华为应用市场分类页 + +```python +import httpx +from bs4 import BeautifulSoup + +async def get_apps_from_category(category_id: str): + """从分类页获取应用列表""" + url = f"https://appgallery.huawei.com/Featured/{category_id}" + + async with httpx.AsyncClient() as client: + response = await client.get(url) + soup = BeautifulSoup(response.text, 'html.parser') + + # 查找应用链接 + app_links = soup.find_all('a', href=True) + app_ids = [] + + for link in app_links: + href = link['href'] + if '/app/' in href: + app_id = href.split('/app/')[-1] + app_ids.append(app_id) + + return app_ids + +# 使用示例 +app_ids = await get_apps_from_category('10000000') # 工具分类 +``` + +#### 方法2:通过应用ID猜测 + +华为应用的 app_id 格式为:`C` + 19位数字 + +可以通过遍历数字范围来发现应用: + +```python +async def guess_app_ids(start: int, end: int): + """猜测应用ID""" + api = HuaweiAPI() + found_apps = [] + + for i in range(start, end): + app_id = f"C{i:019d}" + try: + app_data = await api.get_app_info(app_id=app_id) + found_apps.append({ + 'app_id': app_id, + 'pkg_name': app_data['pkgName'], + 'name': app_data['name'] + }) + print(f"找到应用: {app_data['name']} ({app_data['pkgName']})") + except: + pass + + return found_apps + +# 使用示例 +apps = await guess_app_ids(1164531384803416384, 1164531384803416484) +``` + +#### 方法3:从已有数据库扩展 + +如果已经有一些应用数据,可以通过以下方式扩展: + +1. **同开发者的其他应用** + ```sql + SELECT DISTINCT pkg_name + FROM app_info + WHERE developer_name = '华为软件技术有限公司' + ``` + +2. **同分类的应用** + ```sql + SELECT DISTINCT pkg_name + FROM app_info + WHERE kind_name = '工具' + ``` + +3. **相关推荐应用** + - 访问应用详情页,查看"相关推荐"部分 + - 提取推荐应用的 app_id + +### A.4 常见应用包名示例 + +```python +# 华为系统应用 +HUAWEI_SYSTEM_APPS = [ + "com.huawei.hmsapp.appgallery", # 应用市场 + "com.huawei.browser", # 浏览器 + "com.huawei.music", # 音乐 + "com.huawei.himovie", # 视频 + "com.huawei.camera", # 相机 + "com.huawei.health", # 运动健康 + "com.huawei.wallet", # 钱包 +] + +# 热门第三方应用 +POPULAR_APPS = [ + "com.tencent.mm", # 微信 + "com.tencent.mobileqq", # QQ + "com.sina.weibo", # 微博 + "com.taobao.taobao", # 淘宝 + "com.jingdong.app.mall", # 京东 + "com.ss.android.ugc.aweme", # 抖音 +] + +# 鸿蒙元服务(包名特征) +ATOMIC_SERVICE_PATTERN = "com.atomicservice.*" +``` + +### A.5 包名命名规范 + +包名通常遵循以下规范: + +**格式:** `com.公司名.应用名` + +**示例:** +- `com.huawei.hmsapp.appgallery` - 华为应用市场 +- `com.tencent.mm` - 腾讯微信 +- `com.alibaba.android.rimet` - 阿里钉钉 + +**鸿蒙元服务:** +- `com.atomicservice.{19位数字}` - 元服务包名格式 + +### A.6 实用工具脚本 + +#### 从URL批量提取包名 + +```python +import re +import httpx +from typing import List + +async def extract_pkg_names_from_urls(urls: List[str]) -> List[dict]: + """从URL列表批量提取包名""" + api = HuaweiAPI() + results = [] + + for url in urls: + # 从URL提取app_id + match = re.search(r'/app/([A-Z0-9]+)', url) + if not match: + continue + + app_id = match.group(1) + + try: + app_data = await api.get_app_info(app_id=app_id) + results.append({ + 'url': url, + 'app_id': app_id, + 'pkg_name': app_data['pkgName'], + 'name': app_data['name'] + }) + except Exception as e: + print(f"处理 {url} 失败: {e}") + + return results + +# 使用示例 +urls = [ + "https://appgallery.huawei.com/app/C1164531384803416384", + "https://appgallery.huawei.com/app/C100000000000000001", +] + +results = await extract_pkg_names_from_urls(urls) +for r in results: + print(f"{r['name']}: {r['pkg_name']}") +``` + +#### 导出包名列表 + +```python +import csv +from sqlalchemy import select +from app.models.app_info import AppInfo + +async def export_pkg_names_to_csv(db: AsyncSession, filename: str = "pkg_names.csv"): + """导出所有包名到CSV文件""" + result = await db.execute( + select(AppInfo.pkg_name, AppInfo.name, AppInfo.developer_name) + .order_by(AppInfo.name) + ) + + with open(filename, 'w', newline='', encoding='utf-8') as f: + writer = csv.writer(f) + writer.writerow(['包名', '应用名称', '开发者']) + + for row in result: + writer.writerow([row.pkg_name, row.name, row.developer_name]) + + print(f"已导出到 {filename}") +``` + +### A.7 注意事项 + +1. **包名唯一性** + - 每个应用的包名在华为应用市场中是唯一的 + - 同一个应用在不同应用市场的包名相同 + +2. **包名格式验证** + ```python + import re + + def is_valid_pkg_name(pkg_name: str) -> bool: + """验证包名格式""" + pattern = r'^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$' + return bool(re.match(pattern, pkg_name)) + + # 示例 + print(is_valid_pkg_name("com.huawei.hmsapp.appgallery")) # True + print(is_valid_pkg_name("Com.Huawei.App")) # False (大写) + print(is_valid_pkg_name("huawei.app")) # False (少于2段) + ``` + +3. **元服务识别** + ```python + def is_atomic_service(pkg_name: str) -> bool: + """判断是否为元服务""" + return pkg_name.startswith("com.atomicservice.") + ``` + +4. **获取频率限制** + - 避免过于频繁的请求 + - 建议添加延迟:每次请求间隔 0.5-1 秒 + - 使用批量处理时注意并发数量 + +5. **数据更新策略** + - 优先更新下载量高的应用 + - 定期全量同步所有已知包名 + - 新发现的包名及时入库 + + +--- + +## 7. 部署指南 + +### 7.1 Docker 部署 + +#### 7.1.1 后端 Dockerfile + +```dockerfile +# backend/Dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# 安装 Playwright 依赖 +RUN apt-get update && apt-get install -y \ + libnss3 \ + libnspr4 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libasound2 + +# 复制依赖文件 +COPY requirements.txt . + +# 安装 Python 依赖 +RUN pip install --no-cache-dir -r requirements.txt + +# 安装 Playwright 浏览器 +RUN playwright install chromium + +# 复制应用代码 +COPY . . + +# 暴露端口 +EXPOSE 8000 + +# 启动命令 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +#### 7.1.2 前端 Dockerfile + +```dockerfile +# frontend/Dockerfile +FROM node:18-alpine as builder + +WORKDIR /app + +# 复制依赖文件 +COPY package*.json ./ + +# 安装依赖 +RUN npm ci + +# 复制源代码 +COPY . . + +# 构建 +RUN npm run build + +# 生产环境 +FROM nginx:alpine + +# 复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 复制 Nginx 配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] +``` + +#### 7.1.3 Nginx 配置 + +```nginx +# frontend/nginx.conf +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Gzip 压缩 + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # 前端路由 + location / { + try_files $uri $uri/ /index.html; + } + + # API 代理 + location /api { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} +``` + +#### 7.1.4 Docker Compose + +```yaml +# docker-compose.yml +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: huawei_market_mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./backend/sql:/docker-entrypoint-initdb.d + command: --default-authentication-plugin=mysql_native_password + networks: + - app_network + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: huawei_market_backend + restart: always + environment: + MYSQL_HOST: mysql + MYSQL_PORT: 3306 + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + ports: + - "8000:8000" + depends_on: + - mysql + volumes: + - ./backend:/app + networks: + - app_network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: huawei_market_frontend + restart: always + ports: + - "80:80" + depends_on: + - backend + networks: + - app_network + +volumes: + mysql_data: + +networks: + app_network: + driver: bridge +``` + +#### 7.1.5 环境变量文件 + +```env +# .env +MYSQL_ROOT_PASSWORD=root_password_here +MYSQL_DATABASE=huawei_market +MYSQL_USER=market_user +MYSQL_PASSWORD=user_password_here +``` + +### 7.2 部署步骤 + +#### 7.2.1 准备工作 + +```bash +# 1. 克隆项目 +git clone +cd huawei-market-crawler + +# 2. 创建环境变量文件 +cp .env.example .env +# 编辑 .env 文件,填入实际配置 + +# 3. 创建必要的目录 +mkdir -p backend/logs +mkdir -p mysql_data +``` + +#### 7.2.2 使用 Docker Compose 部署 + +```bash +# 构建并启动所有服务 +docker-compose up -d --build + +# 查看服务状态 +docker-compose ps + +# 查看日志 +docker-compose logs -f backend + +# 停止服务 +docker-compose down + +# 停止并删除数据卷 +docker-compose down -v +``` + +#### 7.2.3 初始化数据库 + +```bash +# 进入 MySQL 容器 +docker exec -it huawei_market_mysql mysql -u root -p + +# 执行初始化脚本 +mysql> USE huawei_market; +mysql> SOURCE /docker-entrypoint-initdb.d/init.sql; +``` + +#### 7.2.4 验证部署 + +```bash +# 检查后端健康状态 +curl http://localhost:8000/health + +# 检查前端 +curl http://localhost/ + +# 测试 API +curl http://localhost:8000/api/market_info +``` + +### 7.3 生产环境优化 + +#### 7.3.1 使用 Gunicorn 运行后端 + +```bash +# 安装 gunicorn +pip install gunicorn + +# 启动命令 +gunicorn app.main:app \ + --workers 4 \ + --worker-class uvicorn.workers.UvicornWorker \ + --bind 0.0.0.0:8000 \ + --access-logfile logs/access.log \ + --error-logfile logs/error.log \ + --log-level info +``` + +#### 7.3.2 MySQL 优化配置 + +```ini +# my.cnf +[mysqld] +# 基础配置 +max_connections = 500 +max_allowed_packet = 64M + +# InnoDB 配置 +innodb_buffer_pool_size = 2G +innodb_log_file_size = 256M +innodb_flush_log_at_trx_commit = 2 +innodb_flush_method = O_DIRECT + +# 查询缓存 +query_cache_type = 1 +query_cache_size = 128M + +# 慢查询日志 +slow_query_log = 1 +slow_query_log_file = /var/log/mysql/slow.log +long_query_time = 2 +``` + +#### 7.3.3 Nginx 生产配置 + +```nginx +# /etc/nginx/sites-available/huawei-market +server { + listen 80; + server_name your-domain.com; + + # 重定向到 HTTPS + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name your-domain.com; + + # SSL 证书 + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + + # SSL 配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # 日志 + access_log /var/log/nginx/huawei-market-access.log; + error_log /var/log/nginx/huawei-market-error.log; + + # 前端 + location / { + root /var/www/huawei-market/frontend; + try_files $uri $uri/ /index.html; + } + + # API + location /api { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 超时设置 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +} +``` + +### 7.4 监控与维护 + +#### 7.4.1 日志管理 + +```python +# app/utils/logger.py +import logging +from logging.handlers import RotatingFileHandler +import os + +def setup_logger(name: str, log_file: str, level=logging.INFO): + """配置日志""" + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + # 确保日志目录存在 + os.makedirs(os.path.dirname(log_file), exist_ok=True) + + # 文件处理器(自动轮转) + file_handler = RotatingFileHandler( + log_file, + maxBytes=10*1024*1024, # 10MB + backupCount=5 + ) + file_handler.setFormatter(formatter) + + # 控制台处理器 + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + + logger = logging.getLogger(name) + logger.setLevel(level) + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + return logger +``` + +#### 7.4.2 健康检查 + +```python +# app/api/health.py +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from app.database import get_db + +router = APIRouter(tags=["健康检查"]) + +@router.get("/health") +async def health_check(db: AsyncSession = Depends(get_db)): + """健康检查""" + try: + # 检查数据库连接 + await db.execute(text("SELECT 1")) + + return { + "status": "healthy", + "database": "connected", + "timestamp": datetime.now().isoformat() + } + except Exception as e: + return { + "status": "unhealthy", + "database": "disconnected", + "error": str(e), + "timestamp": datetime.now().isoformat() + } +``` + +#### 7.4.3 性能监控 + +```bash +# 使用 Prometheus + Grafana 监控 + +# 1. 安装 prometheus-fastapi-instrumentator +pip install prometheus-fastapi-instrumentator + +# 2. 在 main.py 中添加 +from prometheus_fastapi_instrumentator import Instrumentator + +app = FastAPI() +Instrumentator().instrument(app).expose(app) +``` + +### 7.5 备份策略 + +```bash +#!/bin/bash +# backup.sh - 数据库备份脚本 + +BACKUP_DIR="/backup/mysql" +DATE=$(date +%Y%m%d_%H%M%S) +MYSQL_USER="root" +MYSQL_PASSWORD="your_password" +DATABASE="huawei_market" + +# 创建备份目录 +mkdir -p $BACKUP_DIR + +# 备份数据库 +mysqldump -u$MYSQL_USER -p$MYSQL_PASSWORD \ + --single-transaction \ + --routines \ + --triggers \ + $DATABASE > $BACKUP_DIR/backup_$DATE.sql + +# 压缩备份文件 +gzip $BACKUP_DIR/backup_$DATE.sql + +# 删除7天前的备份 +find $BACKUP_DIR -name "backup_*.sql.gz" -mtime +7 -delete + +echo "备份完成: backup_$DATE.sql.gz" +``` + +--- + +## 8. 开发建议与最佳实践 + +### 8.1 代码规范 + +- **Python**: 遵循 PEP 8 规范,使用 Black 格式化 +- **TypeScript**: 使用 ESLint + Prettier +- **提交信息**: 遵循 Conventional Commits 规范 + +### 8.2 测试策略 + +```python +# tests/test_crawler.py +import pytest +from app.crawler.huawei_api import HuaweiAPI + +@pytest.mark.asyncio +async def test_get_app_info(): + api = HuaweiAPI() + data = await api.get_app_info(pkg_name="com.huawei.hmsapp.appgallery") + + assert data['pkgName'] == "com.huawei.hmsapp.appgallery" + assert 'name' in data + assert 'appId' in data + + await api.close() +``` + +### 8.3 性能优化 + +1. **数据库查询优化** + - 使用索引 + - 避免 N+1 查询 + - 使用连接池 + +2. **缓存策略** + - Redis 缓存热门数据 + - 前端使用 LocalStorage + +3. **异步处理** + - 使用异步 I/O + - 批量处理数据 + +### 8.4 安全建议 + +1. **API 安全** + - 添加 API 限流 + - 使用 JWT 认证(如需要) + - 输入验证和清洗 + +2. **数据库安全** + - 使用参数化查询 + - 最小权限原则 + - 定期备份 + +3. **爬虫礼仪** + - 遵守 robots.txt + - 控制请求频率 + - 使用合理的 User-Agent + +--- + +## 9. 常见问题 FAQ + +### Q1: Token 获取失败怎么办? + +**A:** +1. 检查网络连接 +2. 确认 Playwright 浏览器已安装 +3. 尝试手动访问华为应用市场,检查是否需要验证码 +4. 增加等待时间 + +### Q2: 数据库连接超时? + +**A:** +1. 检查 MySQL 服务是否运行 +2. 验证连接配置是否正确 +3. 增加连接池大小 +4. 检查防火墙设置 + +### Q3: 爬取速度太慢? + +**A:** +1. 增加并发数量 +2. 使用批量处理 +3. 优化数据库写入 +4. 考虑使用多台服务器分布式爬取 + +### Q4: 如何处理反爬虫? + +**A:** +1. 降低请求频率 +2. 使用代理IP池 +3. 模拟真实浏览器行为 +4. 定期更新 Token + +--- + +## 10. 参考资源 + +- **FastAPI 文档**: https://fastapi.tiangolo.com/ +- **Vue 3 文档**: https://vuejs.org/ +- **SQLAlchemy 文档**: https://docs.sqlalchemy.org/ +- **Playwright 文档**: https://playwright.dev/python/ +- **MySQL 文档**: https://dev.mysql.com/doc/ + +--- + +## 附录B:完整项目清单 + +### 后端文件清单 +``` +backend/ +├── app/ +│ ├── __init__.py +│ ├── main.py +│ ├── config.py +│ ├── database.py +│ ├── models/ +│ ├── schemas/ +│ ├── api/ +│ ├── crawler/ +│ ├── scheduler/ +│ └── utils/ +├── tests/ +├── logs/ +├── requirements.txt +├── .env +├── Dockerfile +└── README.md +``` + +### 前端文件清单 +``` +frontend/ +├── public/ +├── src/ +│ ├── assets/ +│ ├── components/ +│ ├── views/ +│ ├── api/ +│ ├── stores/ +│ ├── types/ +│ ├── utils/ +│ ├── router/ +│ ├── App.vue +│ └── main.ts +├── package.json +├── vite.config.ts +├── tsconfig.json +├── Dockerfile +├── nginx.conf +└── README.md +``` + +--- + +**文档版本**: v1.0 +**最后更新**: 2024年 +**维护者**: [Your Name] +**许可证**: MIT + + + +--- + +## 附录C:原项目中的包名获取策略 + +原 Rust 项目使用了多种创新的方法来发现和获取应用包名,这些方法非常值得借鉴。 + +### C.1 核心策略概览 + +原项目提供了 **7 个独立工具** 用于获取包名和应用数据: + +| 工具名 | 用途 | 策略 | +|--------|------|------| +| `guess_market` | 应用ID猜测 | 遍历指定范围的应用ID | +| `guess_rand` | 随机猜测 | 随机生成应用ID进行探测 | +| `guess_from_db` | 数据库扩展 | 基于已有数据推测相邻ID | +| `guess_large` | 大规模猜测 | 大范围ID扫描 | +| `get_nextmax` | 第三方数据源 | 从 nextmax.cn 获取 | +| `read_appgallery` | 应用市场爬取 | 直接爬取华为应用市场页面 | +| `read_pkg_name` | 批量导入 | 从文件读取包名列表 | + +### C.2 方法详解 + +#### C.2.1 应用ID猜测法 (guess_market) + +**原理:** 华为应用的 app_id 格式为固定前缀 + 数字,通过遍历数字范围来发现应用。 + +**app_id 格式:** +``` +C576588020785 + 7位数字 +例如: C5765880207856366961 +``` + +**核心代码逻辑:** +```rust +// 定义扫描范围 +let range = 2000000..=6390000; +let start = "C576588020785"; + +// 批量处理(每批1000个) +for bunch_id in range_vec.chunks(1000) { + let mut join_set = tokio::task::JoinSet::new(); + + for id in bunch_id.iter() { + let app_id = format!("{start}{id:07}"); // 格式化为7位数字 + + // 异步请求华为API + join_set.spawn(async move { + if let Ok(data) = query_app(&client, &api_url, &AppQuery::app_id(&app_id), &locale).await { + // 保存到数据库 + db.save_app_data(&data.0, data.1.as_ref(), None, Some(comment)).await + } + }); + } + + join_set.join_all().await; + tokio::time::sleep(Duration::from_millis(25)).await; // 批次间延迟 +} +``` + +**Python 实现示例:** +```python +import asyncio +from typing import List + +async def guess_market_apps( + start_prefix: str = "C576588020785", + start_range: int = 2000000, + end_range: int = 6390000, + batch_size: int = 1000 +): + """通过ID猜测发现应用""" + api = HuaweiAPI() + db = Database() + + for batch_start in range(start_range, end_range, batch_size): + batch_end = min(batch_start + batch_size, end_range) + tasks = [] + + for i in range(batch_start, batch_end): + app_id = f"{start_prefix}{i:07d}" # 7位数字,不足补0 + tasks.append(try_fetch_app(api, db, app_id)) + + # 并发执行 + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 统计结果 + success_count = sum(1 for r in results if not isinstance(r, Exception)) + print(f"批次 {batch_start}-{batch_end}: 成功 {success_count}/{len(tasks)}") + + # 批次间延迟 + await asyncio.sleep(0.025) + +async def try_fetch_app(api: HuaweiAPI, db: Database, app_id: str): + """尝试获取单个应用""" + try: + app_data = await api.get_app_info(app_id=app_id) + rating_data = await api.get_app_rating(app_id) + + await db.save_app_data(app_data, rating_data, comment={ + "user": "guess_market", + "method": "id_guessing" + }) + + print(f"✓ 发现应用: {app_data['name']} ({app_data['pkgName']})") + return True + except Exception as e: + # 应用不存在或请求失败,静默跳过 + return False +``` + +**已知的应用ID前缀:** +```python +KNOWN_APP_ID_PREFIXES = [ + "C576588020785", # 主要前缀 + "C69175", # 另一个前缀系列 + # 可以通过分析已有数据发现更多前缀 +] +``` + +#### C.2.2 随机猜测法 (guess_rand) + +**原理:** 在已知的ID范围内随机生成ID,提高发现效率。 + +**适用场景:** +- ID空间很大,顺序遍历效率低 +- 想要快速发现热门应用(通常ID较新) + +**核心逻辑:** +```rust +let code_start = 59067092904725_u64; +let size = 85170011059280_u64 - code_start; +let start = "C69175"; + +loop { + let mut ids: Vec = Vec::with_capacity(1000); + for _ in 0..1000 { + let id = code_start + (rng.next() % size); // 随机生成 + ids.push(id); + } + + // 批量处理这些随机ID + // ... +} +``` + +**Python 实现:** +```python +import random + +async def guess_random_apps( + prefix: str = "C69175", + start: int = 59067092904725, + end: int = 85170011059280, + batch_size: int = 1000 +): + """随机猜测应用ID""" + api = HuaweiAPI() + db = Database() + + while True: + # 生成随机ID批次 + random_ids = [ + f"{prefix}{random.randint(start, end)}" + for _ in range(batch_size) + ] + + tasks = [try_fetch_app(api, db, app_id) for app_id in random_ids] + results = await asyncio.gather(*tasks, return_exceptions=True) + + success_count = sum(1 for r in results if r is True) + print(f"随机批次: 成功 {success_count}/{batch_size}") + + await asyncio.sleep(0.005) +``` + +#### C.2.3 数据库扩展法 (guess_from_db) + +**原理:** 基于已有的应用ID,推测其相邻的ID可能也是有效应用。 + +**策略:** +1. 从数据库获取所有已知的 app_id +2. 解析每个 app_id 的前缀和数字部分 +3. 对每个数字,生成 ±1000 的范围 +4. 合并重叠的范围 +5. 扫描这些范围 + +**核心逻辑:** +```rust +// 1. 获取所有已知app_id +let existing_app_ids = db.get_all_app_ids().await?; + +// 2. 为每个app_id生成扩展范围 +for app_id in existing_app_ids { + if let Some((prefix, numeric_part)) = parse_app_id(&app_id) { + let start_range = numeric_part.saturating_sub(1000); + let end_range = numeric_part.saturating_add(1000); + all_ranges.insert((prefix, start_range, end_range)); + } +} + +// 3. 合并重叠范围 +// 例如: (100, 1100) 和 (500, 1500) 合并为 (100, 1500) + +// 4. 扫描合并后的范围 +for (prefix, start, end) in merged_ranges { + for id in start..=end { + let app_id = format!("{}{}", prefix, id); + // 尝试获取应用 + } +} +``` + +**Python 实现:** +```python +from typing import Tuple, Optional +import re + +def parse_app_id(app_id: str) -> Optional[Tuple[str, int]]: + """解析app_id,返回(前缀, 数字)""" + match = re.match(r'^([A-Z]+)(\d+)$', app_id) + if match: + return match.group(1), int(match.group(2)) + return None + +async def guess_from_database(expand_range: int = 1000): + """基于数据库已有数据扩展""" + db = Database() + + # 1. 获取所有已知app_id + existing_ids = await db.get_all_app_ids() + + # 2. 生成扩展范围 + ranges = {} + for app_id in existing_ids: + parsed = parse_app_id(app_id) + if not parsed: + continue + + prefix, num = parsed + start = max(0, num - expand_range) + end = num + expand_range + + if prefix not in ranges: + ranges[prefix] = [] + ranges[prefix].append((start, end)) + + # 3. 合并重叠范围 + merged_ranges = {} + for prefix, range_list in ranges.items(): + range_list.sort() + merged = [] + current = range_list[0] + + for r in range_list[1:]: + if r[0] <= current[1] + 1: + # 重叠或相邻,合并 + current = (current[0], max(current[1], r[1])) + else: + merged.append(current) + current = r + merged.append(current) + merged_ranges[prefix] = merged + + # 4. 扫描范围 + api = HuaweiAPI() + for prefix, range_list in merged_ranges.items(): + for start, end in range_list: + print(f"扫描范围: {prefix}{start} - {prefix}{end}") + await guess_market_apps(prefix, start, end) +``` + +#### C.2.4 从文件批量导入 (read_pkg_name) + +**原理:** 从文本文件读取包名列表,批量获取应用数据。 + +**使用方式:** +```bash +# 创建包名列表文件 +cat > pkg_names.txt << EOF +com.huawei.hmsapp.appgallery +com.tencent.mm +com.sina.weibo +EOF + +# 运行工具 +cargo run --bin read_pkg_name pkg_names.txt +``` + +**核心代码:** +```rust +// 从命令行参数获取文件路径 +let cli_file = std::env::args().nth(1).ok_or_else(|| anyhow::anyhow!("No file path provided"))?; + +// 读取文件中的包名 +let pkg_names: Vec = { + let file = std::fs::File::open(&cli_file)?; + let mut reader = std::io::BufReader::new(file); + let mut pkg_names = Vec::new(); + let mut line = String::new(); + while reader.read_line(&mut line)? > 0 { + pkg_names.push(line.trim().to_string()); + line.clear(); + } + pkg_names.into_iter() + .map(|l| l.trim_matches('\"').to_string()) + .collect() +}; + +// 批量同步 +sync::sync_all(&client, &db, &config).await?; +``` + +**Python 实现:** +```python +async def read_pkg_names_from_file(filepath: str): + """从文件读取包名并批量获取""" + # 读取包名列表 + with open(filepath, 'r', encoding='utf-8') as f: + pkg_names = [ + line.strip().strip('"').strip("'") + for line in f + if line.strip() + ] + + print(f"从文件读取到 {len(pkg_names)} 个包名") + + # 批量获取 + api = HuaweiAPI() + db = Database() + + for i in range(0, len(pkg_names), 100): + batch = pkg_names[i:i+100] + tasks = [ + fetch_and_save_app(api, db, pkg_name) + for pkg_name in batch + ] + await asyncio.gather(*tasks, return_exceptions=True) + print(f"已处理 {min(i+100, len(pkg_names))}/{len(pkg_names)}") + +async def fetch_and_save_app(api: HuaweiAPI, db: Database, pkg_name: str): + """获取并保存单个应用""" + try: + app_data = await api.get_app_info(pkg_name=pkg_name) + rating_data = await api.get_app_rating(app_data['appId']) + await db.save_app_data(app_data, rating_data) + print(f"✓ {pkg_name}") + except Exception as e: + print(f"✗ {pkg_name}: {e}") +``` + +#### C.2.5 Substance(主题/合集)批量获取 + +**原理:** 华为应用市场有"主题"或"合集"功能,一个 substance 包含多个应用。 + +**Substance ID 格式:** +``` +例如: webAgSubstanceDetail|12345 +``` + +**核心逻辑:** +```rust +pub async fn get_app_from_substance( + client: &reqwest::Client, + api_url: &str, + substance_id: impl ToString, +) -> Result<(SubstanceData, JsonValue)> { + // 1. 请求 substance 详情 + let body = serde_json::json!({ + "pageId": format!("webAgSubstanceDetail|{}", substance_id.to_string()), + "pageNum": 1, + "pageSize": 100, + "zone": "", + "businessParam": { "animation": 0 } + }); + + let response = client.post(format!("{api_url}/harmony/page-detail")) + .json(&body) + .send() + .await?; + + let data = response.json::().await?; + + // 2. 解析卡片数据,提取应用ID + let layouts = data["pages"][0]["data"]["cardlist"]["layoutData"].as_array()?; + + let mut apps = Vec::new(); + for card in layouts { + match card["type"].as_str()? { + "com.huawei.hmsapp.appgallery.verticallistcard" => { + // 竖向列表卡片 + for app in card["data"].as_array()? { + if let Some(app_id) = app.get("appId") { + apps.push(AppQuery::app_id(app_id.as_str()?)); + } + } + } + "com.huawei.hmos.appgallery.scenariolistcard.landing" => { + // 场景列表卡片 + let refs_list = card["data"][0]["refsList_app"].as_array()?; + for app in refs_list { + if let Some(app_id) = app.get("appId") { + apps.push(AppQuery::app_id(app_id.as_str()?)); + } + } + } + _ => {} + } + } + + // 3. 如果有更多页,继续获取 + if data["hasMore"].as_i64()? != 0 { + let more_apps = get_more_substance(client, api_url, card_id).await?; + apps.extend(more_apps); + } + + Ok((SubstanceData { id, title, apps }, data)) +} +``` + +**Python 实现:** +```python +async def get_apps_from_substance(substance_id: str) -> List[str]: + """从主题/合集获取应用列表""" + api = HuaweiAPI() + + url = f"{api.base_url}/harmony/page-detail" + body = { + "pageId": f"webAgSubstanceDetail|{substance_id}", + "pageNum": 1, + "pageSize": 100, + "zone": "", + "businessParam": {"animation": 0} + } + + tokens = await api.token_manager.get_token() + headers = { + "Content-Type": "application/json", + "Interface-Code": tokens["interface_code"], + "identity-id": tokens["identity_id"] + } + + response = await api.client.post(url, json=body, headers=headers) + data = response.json() + + app_ids = [] + layouts = data["pages"][0]["data"]["cardlist"]["layoutData"] + + for card in layouts: + card_type = card.get("type", "") + card_data = card.get("data", []) + + if card_type == "com.huawei.hmsapp.appgallery.verticallistcard": + for app in card_data: + if "appId" in app: + app_ids.append(app["appId"]) + + elif card_type == "com.huawei.hmos.appgallery.scenariolistcard.landing": + if card_data and "refsList_app" in card_data[0]: + for app in card_data[0]["refsList_app"]: + if "appId" in app: + app_ids.append(app["appId"]) + + # 处理分页 + if data.get("hasMore", 0) != 0: + card_id = data["cardlist"]["dataId"] + more_apps = await get_more_substance_pages(api, card_id) + app_ids.extend(more_apps) + + return app_ids + +async def get_more_substance_pages(api: HuaweiAPI, card_id: str) -> List[str]: + """获取主题的更多页""" + app_ids = [] + page_num = 2 + has_more = True + + while has_more: + url = f"{api.base_url}/harmony/card-list" + body = { + "dataId": card_id, + "locale": "zh", + "pageNum": page_num, + "pageSize": 25 + } + + response = await api.client.post(url, json=body) + data = response.json() + + has_more = data.get("hasMore", 0) != 0 + page_num += 1 + + for card in data.get("layoutData", []): + if card.get("type") == "com.huawei.hmsapp.appgallery.verticallistcard": + for app in card.get("data", []): + if "appId" in app: + app_ids.append(app["appId"]) + + return app_ids +``` + +### C.3 综合策略建议 + +**初始阶段(冷启动):** +1. 使用 `guess_market` 扫描已知的ID范围 +2. 从华为应用市场首页爬取热门应用 +3. 手动收集一些知名应用的包名 + +**扩展阶段:** +1. 使用 `guess_from_db` 基于已有数据扩展 +2. 使用 `guess_rand` 随机发现新应用 +3. 定期从 substance(主题合集)批量获取 + +**维护阶段:** +1. 定期同步已知包名的数据更新 +2. 监控新应用ID的出现模式 +3. 从用户投稿获取新包名 + +**效率优化:** +```python +# 组合策略示例 +async def comprehensive_discovery(): + """综合发现策略""" + + # 1. 先从数据库扩展(成功率高) + await guess_from_database(expand_range=500) + + # 2. 扫描热门ID段 + await guess_market_apps("C576588020785", 6000000, 6400000) + + # 3. 随机探测(发现新应用) + asyncio.create_task(guess_random_apps()) # 后台运行 + + # 4. 定期同步已知应用 + await sync_known_apps() +``` + +### C.4 注意事项 + +1. **请求频率控制** + - 批次间延迟:25-50ms + - 单个请求超时:30秒 + - 并发数:建议不超过1000 + +2. **错误处理** + - 应用不存在:静默跳过 + - 网络错误:重试3次 + - Token过期:自动刷新 + +3. **数据去重** + - 使用 app_id 或 pkg_name 作为唯一标识 + - 插入前检查数据库是否已存在 + +4. **性能监控** + - 记录成功率(发现率) + - 监控请求耗时 + - 统计每小时发现的新应用数 + +这些方法的组合使用,使得原项目能够高效地发现和收集华为应用市场的应用数据。 +