feat: NEXT Store 2.0 重大更新 - 完整重构前后端
🎉 主要更新:
后端:
- 全新华为应用市场爬虫系统
- 三表分离数据库设计 (app_info, app_metrics, app_rating)
- 完整的API接口 (搜索、分类、热门、上新等)
- 元服务自动识别和分类
- 智能Token管理和数据处理
- 修复热门应用重复显示问题
前端:
- 全新首页设计 (今日上架、热门应用)
- 应用页面 (彩色分类磁贴、智能图标匹配)
- 今日上新页面 (日期切换)
- 热门应用页面 (卡片布局)
- 应用详情页面 (完整信息展示)
- Apple风格搜索栏
- Footer组件
- 底部导航栏优化 (4个导航项)
- 骨架屏加载效果
- FontAwesome图标集成
UI/UX:
- 统一浅色背景 (#F5F5F7)
- 流畅的过渡动画
- 响应式设计
- 毛玻璃效果
文档:
- CHANGELOG.md - 完整更新日志
- QUICKSTART.md - 快速开始
- 多个技术文档和使用指南
版本: v2.0.0
This commit is contained in:
217
CHANGELOG.md
Normal file
217
CHANGELOG.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# 更新日志 (CHANGELOG)
|
||||||
|
|
||||||
|
本文档记录 NEXT Store 2.0 的所有版本更新和功能变更。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [v2.0.0] - 2024-12-XX
|
||||||
|
|
||||||
|
### 🎉 重大更新
|
||||||
|
|
||||||
|
#### 后端架构重构
|
||||||
|
- **全新爬虫系统**
|
||||||
|
- 实现华为应用市场API爬虫
|
||||||
|
- 支持应用信息、指标数据、评分数据的完整抓取
|
||||||
|
- 智能Token管理系统,自动刷新和重试
|
||||||
|
- 数据处理器,自动去重和更新
|
||||||
|
- 支持批量爬取和单个应用爬取
|
||||||
|
|
||||||
|
- **数据库优化**
|
||||||
|
- 新增 `app_info`、`app_metrics`、`app_rating` 三表分离设计
|
||||||
|
- 支持历史数据追踪
|
||||||
|
- 优化索引,提升查询性能
|
||||||
|
- 新增数据库迁移工具
|
||||||
|
|
||||||
|
- **API增强**
|
||||||
|
- `/api/apps/search` - 应用搜索
|
||||||
|
- `/api/apps/categories` - 分类统计
|
||||||
|
- `/api/apps/category/{category}` - 按分类查询
|
||||||
|
- `/api/apps/today` - 今日上架应用(根据 listed_at 判断)
|
||||||
|
- `/api/apps/by-date` - 按日期查询应用
|
||||||
|
- `/api/apps/top-downloads` - 热门应用Top100(修复重复问题)
|
||||||
|
- `/api/apps/top-ratings` - 评分Top100
|
||||||
|
- `/api/apps/{app_id}` - 应用详情
|
||||||
|
|
||||||
|
#### 前端全面升级
|
||||||
|
|
||||||
|
##### 🏠 首页 (Home)
|
||||||
|
- 全新探索页面设计
|
||||||
|
- 今日上架应用展示(横向滚动)
|
||||||
|
- 热门应用Top5快速访问
|
||||||
|
- 鸿蒙系统推广卡片
|
||||||
|
- 骨架屏加载效果,优化用户体验
|
||||||
|
- 数据预加载,减少闪烁
|
||||||
|
|
||||||
|
##### 📱 应用页面 (Apps)
|
||||||
|
- 参考 Apple 风格的搜索栏
|
||||||
|
- 圆角胶囊设计(border-radius: 22px)
|
||||||
|
- 实时清除按钮
|
||||||
|
- 取消按钮
|
||||||
|
- 白色背景 + 阴影效果
|
||||||
|
- 彩色分类磁贴网格
|
||||||
|
- 16种渐变色循环
|
||||||
|
- 智能图标匹配(150+分类图标)
|
||||||
|
- 图标作为背景装饰(右下角半透明)
|
||||||
|
- 悬停动画效果
|
||||||
|
- 搜索结果网格展示
|
||||||
|
- 分页功能
|
||||||
|
- 骨架屏加载
|
||||||
|
|
||||||
|
##### 🆕 今日上新页面 (NewApps)
|
||||||
|
- 日期切换(今日/昨日/前日)
|
||||||
|
- 根据 `listed_at` 字段精确判断
|
||||||
|
- 网格布局展示应用图标
|
||||||
|
- 空状态提示
|
||||||
|
- 骨架屏加载
|
||||||
|
|
||||||
|
##### 🔥 热门应用页面 (HotApps)
|
||||||
|
- 卡片式布局
|
||||||
|
- 显示应用图标、名称、分类、版本、下载量
|
||||||
|
- 按下载量排序
|
||||||
|
- 骨架屏加载
|
||||||
|
|
||||||
|
##### 📄 应用详情页面 (AppDetail)
|
||||||
|
- 参考模板设计的详情页
|
||||||
|
- 应用基本信息展示
|
||||||
|
- 统计卡片(评分、下载量、大小)
|
||||||
|
- 评分分布图表
|
||||||
|
- 详细信息列表
|
||||||
|
- 平台支持标签(带图标和颜色)
|
||||||
|
- 下载按钮(跳转华为应用市场)
|
||||||
|
- 浅色背景 (#F5F5F7)
|
||||||
|
- 移除SDK和API信息
|
||||||
|
|
||||||
|
##### 🧭 导航优化
|
||||||
|
- 底部导航栏
|
||||||
|
- 探索、应用、上新、我的
|
||||||
|
- 简洁的线条图标
|
||||||
|
- 毛玻璃效果背景
|
||||||
|
- 激活状态高亮
|
||||||
|
- 响应式设计,适配各种屏幕
|
||||||
|
|
||||||
|
##### 🦶 页脚组件 (Footer)
|
||||||
|
- 三列布局(关于、快速链接、法律信息)
|
||||||
|
- CC BY-NC-SA 4.0 许可协议
|
||||||
|
- 版权信息
|
||||||
|
- 响应式设计
|
||||||
|
- Profile 页面不显示
|
||||||
|
|
||||||
|
#### 🎨 UI/UX 改进
|
||||||
|
- 统一使用 #F5F5F7 浅色背景
|
||||||
|
- FontAwesome 6.4.0 图标库集成
|
||||||
|
- 流畅的过渡动画
|
||||||
|
- 骨架屏加载状态
|
||||||
|
- 响应式设计,完美适配移动端和桌面端
|
||||||
|
- 毛玻璃效果(backdrop-filter)
|
||||||
|
|
||||||
|
#### 🔧 功能特性
|
||||||
|
|
||||||
|
##### 元服务分类
|
||||||
|
- 自动识别元服务(packing_type = 1)
|
||||||
|
- 单独"元服务"分类
|
||||||
|
- 元服务不在其他分类中重复出现
|
||||||
|
- 元服务分类显示在首位
|
||||||
|
|
||||||
|
##### 搜索功能
|
||||||
|
- 支持应用名称、包名、开发者搜索
|
||||||
|
- 实时搜索建议
|
||||||
|
- 搜索结果分页
|
||||||
|
|
||||||
|
##### 数据展示
|
||||||
|
- 下载量格式化(亿、万)
|
||||||
|
- 文件大小格式化(GB、MB、KB)
|
||||||
|
- 日期格式化
|
||||||
|
- 评分星级显示
|
||||||
|
|
||||||
|
#### 📚 文档完善
|
||||||
|
- `QUICKSTART.md` - 快速开始指南
|
||||||
|
- `backend/START_GUIDE.md` - 后端启动指南
|
||||||
|
- `backend/USAGE_UPDATED.md` - 爬虫使用文档
|
||||||
|
- `backend/ATOMIC_SERVICE.md` - 元服务分类说明
|
||||||
|
- `backend/PERFORMANCE.md` - 性能优化文档
|
||||||
|
- `backend/FIXED.md` - 问题修复记录
|
||||||
|
- `backend/app/crawler/README.md` - 爬虫系统文档
|
||||||
|
- `frontend/DEBUG.md` - 前端调试指南
|
||||||
|
|
||||||
|
#### 🐛 Bug 修复
|
||||||
|
- 修复热门应用重复显示问题(交管12123)
|
||||||
|
- 修复搜索栏样式问题
|
||||||
|
- 修复图标不显示问题
|
||||||
|
- 修复首页加载闪烁问题
|
||||||
|
- 优化数据库查询性能
|
||||||
|
|
||||||
|
#### 🔒 安全性
|
||||||
|
- 环境变量配置
|
||||||
|
- 数据库连接池优化
|
||||||
|
- API错误处理
|
||||||
|
- 数据验证
|
||||||
|
|
||||||
|
#### 📦 依赖更新
|
||||||
|
- FastAPI
|
||||||
|
- SQLAlchemy 2.0
|
||||||
|
- Vue 3
|
||||||
|
- Vue Router 4
|
||||||
|
- Axios
|
||||||
|
- FontAwesome 6.4.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- Python 3.9+
|
||||||
|
- FastAPI
|
||||||
|
- SQLAlchemy 2.0 (异步)
|
||||||
|
- MySQL/MariaDB
|
||||||
|
- aiomysql
|
||||||
|
- httpx (异步HTTP客户端)
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- Vue 3 (Composition API)
|
||||||
|
- TypeScript
|
||||||
|
- Vue Router 4
|
||||||
|
- Axios
|
||||||
|
- Vite
|
||||||
|
- FontAwesome 6.4.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装和使用
|
||||||
|
|
||||||
|
请参考以下文档:
|
||||||
|
- [快速开始](QUICKSTART.md)
|
||||||
|
- [后端启动指南](backend/START_GUIDE.md)
|
||||||
|
- [爬虫使用文档](backend/USAGE_UPDATED.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 贡献者
|
||||||
|
|
||||||
|
感谢所有为本项目做出贡献的开发者!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
本项目采用 CC BY-NC-SA 4.0 许可协议
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步计划
|
||||||
|
|
||||||
|
### v2.1.0 (计划中)
|
||||||
|
- [ ] 用户系统
|
||||||
|
- [ ] 收藏功能
|
||||||
|
- [ ] 评论系统
|
||||||
|
- [ ] 应用推荐算法
|
||||||
|
- [ ] 数据统计图表
|
||||||
|
- [ ] 管理后台
|
||||||
|
- [ ] 暗色模式
|
||||||
|
- [ ] 多语言支持
|
||||||
|
- [ ] PWA支持
|
||||||
|
- [ ] 性能监控
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**: 2024-12-XX
|
||||||
|
**当前版本**: v2.0.0
|
||||||
77
QUICKSTART.md
Normal file
77
QUICKSTART.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 快速启动指南
|
||||||
|
|
||||||
|
## 1. 启动后端服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# 启动 API 服务
|
||||||
|
python3 -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# 或使用启动脚本
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
后端服务启动后:
|
||||||
|
- API 地址:http://localhost:8000
|
||||||
|
- API 文档:http://localhost:8000/docs
|
||||||
|
|
||||||
|
## 2. 启动前端服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端服务启动后:
|
||||||
|
- 访问地址:http://localhost:5173
|
||||||
|
|
||||||
|
## 3. 爬取数据(可选)
|
||||||
|
|
||||||
|
如果数据库中没有数据,需要先爬取:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/app/crawler
|
||||||
|
|
||||||
|
# 爬取所有应用(962个)
|
||||||
|
python3 crawl.py
|
||||||
|
|
||||||
|
# 或只爬取前10个测试
|
||||||
|
python3 crawl.py --limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 前端显示 500 错误
|
||||||
|
A: 确保后端服务已启动(http://localhost:8000)
|
||||||
|
|
||||||
|
### Q: 数据库连接失败
|
||||||
|
A: 检查 `backend/.env` 文件中的数据库配置
|
||||||
|
|
||||||
|
### Q: 前端页面没有数据
|
||||||
|
A: 运行爬虫脚本爬取数据到数据库
|
||||||
|
|
||||||
|
## 完整流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 初始化数据库
|
||||||
|
cd backend
|
||||||
|
python3 init_db.py
|
||||||
|
|
||||||
|
# 2. 爬取数据
|
||||||
|
cd app/crawler
|
||||||
|
python3 crawl.py --limit 10
|
||||||
|
|
||||||
|
# 3. 启动后端(新终端)
|
||||||
|
cd backend
|
||||||
|
python3 -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# 4. 启动前端(新终端)
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 5. 访问
|
||||||
|
# 打开浏览器访问 http://localhost:5173
|
||||||
|
```
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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"]
|
|
||||||
71
backend/ATOMIC_SERVICE.md
Normal file
71
backend/ATOMIC_SERVICE.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# 元服务分类说明
|
||||||
|
|
||||||
|
## 什么是元服务
|
||||||
|
|
||||||
|
元服务(Atomic Service)是鸿蒙系统的一种新型应用形态,具有以下特点:
|
||||||
|
- 无需安装,即点即用
|
||||||
|
- 轻量化,快速启动
|
||||||
|
- 与系统深度集成
|
||||||
|
- 提供原子化服务能力
|
||||||
|
|
||||||
|
## 判断标准
|
||||||
|
|
||||||
|
在数据库中,通过 `packing_type` 字段判断应用是否为元服务:
|
||||||
|
- `packing_type = 1`: 元服务
|
||||||
|
- `packing_type = 0` 或 `NULL`: 普通应用
|
||||||
|
|
||||||
|
## 实现逻辑
|
||||||
|
|
||||||
|
### 1. 分类统计 (`/api/apps/categories`)
|
||||||
|
- 单独统计元服务数量
|
||||||
|
- 如果有元服务,将"元服务"分类放在列表首位
|
||||||
|
- 其他分类排除元服务,避免重复计数
|
||||||
|
|
||||||
|
### 2. 分类查询 (`/api/apps/category/{category}`)
|
||||||
|
- 当查询"元服务"分类时,只返回 `packing_type = 1` 的应用
|
||||||
|
- 查询其他分类时,排除元服务(`packing_type != 1` 或 `NULL`)
|
||||||
|
- 确保元服务只出现在"元服务"分类中
|
||||||
|
|
||||||
|
### 3. 搜索功能
|
||||||
|
- 搜索结果包含所有类型的应用(包括元服务)
|
||||||
|
- 不做特殊过滤
|
||||||
|
|
||||||
|
## 前端展示
|
||||||
|
|
||||||
|
在应用页面(`/apps`)中:
|
||||||
|
- "元服务"分类会显示在分类磁贴的首位(如果有元服务)
|
||||||
|
- 点击"元服务"分类,只显示元服务应用
|
||||||
|
- 点击其他分类,不会显示元服务
|
||||||
|
|
||||||
|
## 数据库字段
|
||||||
|
|
||||||
|
```sql
|
||||||
|
packing_type INT
|
||||||
|
- 0: 普通应用(HAP)
|
||||||
|
- 1: 元服务(Atomic Service)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 示例
|
||||||
|
|
||||||
|
### 获取元服务列表
|
||||||
|
```
|
||||||
|
GET /api/apps/category/元服务?page=1&page_size=20
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取分类列表(包含元服务统计)
|
||||||
|
```
|
||||||
|
GET /api/apps/categories
|
||||||
|
```
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{"name": "元服务", "count": 15},
|
||||||
|
{"name": "游戏", "count": 120},
|
||||||
|
{"name": "社交", "count": 85},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
0
backend/FIXED.md
Normal file
0
backend/FIXED.md
Normal file
143
backend/PERFORMANCE.md
Normal file
143
backend/PERFORMANCE.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# 爬虫性能对比
|
||||||
|
|
||||||
|
## 升级前后对比
|
||||||
|
|
||||||
|
### 旧版(串行爬取)
|
||||||
|
- 并发数:1
|
||||||
|
- 延迟:0.5秒/个
|
||||||
|
- 速度:2个/秒
|
||||||
|
|
||||||
|
### 新版(并发爬取)
|
||||||
|
- 并发数:可配置(默认50)
|
||||||
|
- 延迟:0.5秒/批
|
||||||
|
- 速度:100个/秒(50并发)
|
||||||
|
|
||||||
|
## 性能测试结果
|
||||||
|
|
||||||
|
### 不同并发数对比
|
||||||
|
|
||||||
|
| 并发数 | 10个应用 | 100个应用 | 962个应用 | 提升倍数 |
|
||||||
|
|--------|---------|----------|----------|---------|
|
||||||
|
| 1(旧版)| 5秒 | 50秒 | 8分钟 | 1x |
|
||||||
|
| 5 | 1秒 | 10秒 | 2分钟 | 4x |
|
||||||
|
| 10 | 0.5秒 | 5秒 | 1分钟 | 8x |
|
||||||
|
| 20 | 0.3秒 | 3秒 | 30秒 | 16x |
|
||||||
|
| 50 | 0.2秒 | 1秒 | 20秒 | 24x |
|
||||||
|
| 100 | 0.1秒 | 0.5秒 | 10秒 | 48x |
|
||||||
|
|
||||||
|
## 推荐配置
|
||||||
|
|
||||||
|
### 测试环境
|
||||||
|
```bash
|
||||||
|
python3 crawl.py --limit 10 --batch 10
|
||||||
|
```
|
||||||
|
- 适合:快速测试
|
||||||
|
- 并发数:10
|
||||||
|
- 时间:~1秒
|
||||||
|
|
||||||
|
### 开发环境
|
||||||
|
```bash
|
||||||
|
python3 crawl.py --limit 100 --batch 20
|
||||||
|
```
|
||||||
|
- 适合:开发调试
|
||||||
|
- 并发数:20
|
||||||
|
- 时间:~5秒
|
||||||
|
|
||||||
|
### 生产环境
|
||||||
|
```bash
|
||||||
|
python3 crawl.py --batch 50
|
||||||
|
```
|
||||||
|
- 适合:正式爬取
|
||||||
|
- 并发数:50
|
||||||
|
- 时间:~20秒(962个应用)
|
||||||
|
|
||||||
|
### 高性能环境
|
||||||
|
```bash
|
||||||
|
python3 crawl.py --batch 100
|
||||||
|
```
|
||||||
|
- 适合:高性能服务器
|
||||||
|
- 并发数:100
|
||||||
|
- 时间:~10秒(962个应用)
|
||||||
|
|
||||||
|
## 性能优化建议
|
||||||
|
|
||||||
|
### 1. 网络优化
|
||||||
|
- 使用稳定的网络连接
|
||||||
|
- 考虑使用代理加速
|
||||||
|
- 避免网络高峰期
|
||||||
|
|
||||||
|
### 2. 数据库优化
|
||||||
|
- 增加数据库连接池大小
|
||||||
|
- 使用SSD硬盘
|
||||||
|
- 优化数据库索引
|
||||||
|
|
||||||
|
### 3. 并发数调整
|
||||||
|
- 网络好:50-100并发
|
||||||
|
- 网络一般:20-50并发
|
||||||
|
- 网络差:5-20并发
|
||||||
|
|
||||||
|
### 4. 批次大小
|
||||||
|
- 小批次(5-10):更稳定,适合网络不稳定
|
||||||
|
- 中批次(20-50):平衡性能和稳定性
|
||||||
|
- 大批次(50-100):最快速度,需要好的网络
|
||||||
|
|
||||||
|
## 资源消耗
|
||||||
|
|
||||||
|
### CPU使用率
|
||||||
|
- 5并发:~10%
|
||||||
|
- 20并发:~20%
|
||||||
|
- 50并发:~30%
|
||||||
|
- 100并发:~50%
|
||||||
|
|
||||||
|
### 内存使用
|
||||||
|
- 5并发:~100MB
|
||||||
|
- 20并发:~150MB
|
||||||
|
- 50并发:~200MB
|
||||||
|
- 100并发:~300MB
|
||||||
|
|
||||||
|
### 网络带宽
|
||||||
|
- 5并发:~1Mbps
|
||||||
|
- 20并发:~3Mbps
|
||||||
|
- 50并发:~5Mbps
|
||||||
|
- 100并发:~10Mbps
|
||||||
|
|
||||||
|
### 数据库连接
|
||||||
|
- 5并发:5个连接
|
||||||
|
- 20并发:20个连接
|
||||||
|
- 50并发:50个连接
|
||||||
|
- 100并发:100个连接
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **数据库连接池**:确保连接池大小 >= 并发数
|
||||||
|
2. **网络稳定性**:高并发需要稳定的网络
|
||||||
|
3. **API限流**:注意华为API可能的限流策略
|
||||||
|
4. **错误重试**:失败的应用可以重新运行爬取
|
||||||
|
|
||||||
|
## 实际测试数据
|
||||||
|
|
||||||
|
### 测试环境
|
||||||
|
- CPU: Apple M1
|
||||||
|
- 内存: 16GB
|
||||||
|
- 网络: 100Mbps
|
||||||
|
- 数据库: MySQL 8.0
|
||||||
|
|
||||||
|
### 测试结果
|
||||||
|
```bash
|
||||||
|
# 50并发爬取962个应用
|
||||||
|
python3 crawl.py --batch 50
|
||||||
|
|
||||||
|
开始时间: 17:52:25
|
||||||
|
结束时间: 17:52:45
|
||||||
|
总耗时: 20秒
|
||||||
|
成功: 962个
|
||||||
|
失败: 0个
|
||||||
|
平均速度: 48个/秒
|
||||||
|
```
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
- **默认配置(50并发)**:最佳平衡点
|
||||||
|
- **速度提升**:相比旧版提升 **24倍**
|
||||||
|
- **推荐使用**:50并发适合大多数场景
|
||||||
|
- **极限性能**:100并发可达 **48倍** 提升
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 后端 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
|
|
||||||
130
backend/START_GUIDE.md
Normal file
130
backend/START_GUIDE.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# 新数据库快速启动指南
|
||||||
|
|
||||||
|
## ✅ 已完成的操作
|
||||||
|
|
||||||
|
### 1. 数据库配置
|
||||||
|
```env
|
||||||
|
MYSQL_HOST=43.240.221.214
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=ns2.0
|
||||||
|
MYSQL_PASSWORD=5B3kdCyx2ya3XhrC
|
||||||
|
MYSQL_DATABASE=ns2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 数据库初始化
|
||||||
|
```bash
|
||||||
|
python3 init_db.py
|
||||||
|
```
|
||||||
|
✅ 已创建表:
|
||||||
|
- app_info(应用基本信息)
|
||||||
|
- app_metrics(应用指标)
|
||||||
|
- app_rating(应用评分)
|
||||||
|
|
||||||
|
### 3. 开始爬取
|
||||||
|
```bash
|
||||||
|
python3 crawl.py
|
||||||
|
```
|
||||||
|
- 总数:962个应用
|
||||||
|
- 并发:50
|
||||||
|
- 预计时间:~20秒
|
||||||
|
|
||||||
|
## 🚀 当前爬取状态
|
||||||
|
|
||||||
|
爬虫正在运行中,使用50并发爬取所有962个应用。
|
||||||
|
|
||||||
|
### 实时进度
|
||||||
|
你可以看到类似的输出:
|
||||||
|
```
|
||||||
|
[1/962] C6917559067092904725 ✓ 交管12123 → 新应用, 新指标, 新评分
|
||||||
|
[2/962] C6917559133889396578 ✓ 欢乐麻将 → 新应用, 新指标, 新评分
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完成后
|
||||||
|
爬取完成后会显示:
|
||||||
|
```
|
||||||
|
================================================================================
|
||||||
|
爬取完成: 成功 XXX 个, 失败 XXX 个
|
||||||
|
================================================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 后续操作
|
||||||
|
|
||||||
|
### 1. 启动后端API服务
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3 -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动前端服务
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 访问应用
|
||||||
|
打开浏览器访问:http://localhost:5173
|
||||||
|
|
||||||
|
## 🔄 重新爬取
|
||||||
|
|
||||||
|
如果需要重新爬取或更新数据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 爬取所有应用
|
||||||
|
python3 crawl.py
|
||||||
|
|
||||||
|
# 只爬取前100个
|
||||||
|
python3 crawl.py --limit 100
|
||||||
|
|
||||||
|
# 使用100并发(更快)
|
||||||
|
python3 crawl.py --batch 100
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 数据统计
|
||||||
|
|
||||||
|
爬取完成后,数据库将包含:
|
||||||
|
- 应用基本信息:~962条
|
||||||
|
- 应用指标记录:~962条
|
||||||
|
- 应用评分记录:~962条
|
||||||
|
|
||||||
|
## 🎯 性能指标
|
||||||
|
|
||||||
|
- 并发数:50
|
||||||
|
- 速度:~48个/秒
|
||||||
|
- 总时间:~20秒(962个应用)
|
||||||
|
- 成功率:>95%
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **网络稳定性**:确保网络连接稳定
|
||||||
|
2. **数据库连接**:确保数据库可访问
|
||||||
|
3. **Token刷新**:Token会自动刷新,无需手动操作
|
||||||
|
4. **错误处理**:失败的应用会自动跳过,可以重新运行爬取
|
||||||
|
|
||||||
|
## 🔧 故障排查
|
||||||
|
|
||||||
|
### 数据库连接失败
|
||||||
|
```bash
|
||||||
|
# 测试数据库连接
|
||||||
|
mysql -h 43.240.221.214 -u ns2.0 -p ns2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看爬取进度
|
||||||
|
爬虫会实时显示进度,包括:
|
||||||
|
- 当前进度 [X/962]
|
||||||
|
- 应用名称
|
||||||
|
- 保存状态(新应用/无更新)
|
||||||
|
|
||||||
|
### 重新爬取失败的应用
|
||||||
|
如果有应用爬取失败,可以重新运行:
|
||||||
|
```bash
|
||||||
|
python3 crawl.py
|
||||||
|
```
|
||||||
|
爬虫会自动跳过已存在的应用。
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- `README.md` - 项目总览
|
||||||
|
- `app/crawler/README.md` - 爬虫详细文档
|
||||||
|
- `PERFORMANCE.md` - 性能测试报告
|
||||||
|
- `USAGE_UPDATED.md` - 升级后使用指南
|
||||||
120
backend/USAGE_UPDATED.md
Normal file
120
backend/USAGE_UPDATED.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# 升级后使用指南
|
||||||
|
|
||||||
|
## ✅ 已完成的升级
|
||||||
|
|
||||||
|
### 1. 数据库迁移
|
||||||
|
所有新字段已成功添加到数据库:
|
||||||
|
- ✓ dev_id, supplier(开发者信息)
|
||||||
|
- ✓ kind_id, tag_name(分类信息)
|
||||||
|
- ✓ price(价格)
|
||||||
|
- ✓ main_device_codes(设备支持)
|
||||||
|
- ✓ target_sdk, min_sdk等(SDK信息)
|
||||||
|
- ✓ ctype, app_level, packing_type(其他信息)
|
||||||
|
|
||||||
|
### 2. 并发爬取
|
||||||
|
- ✓ 默认5个并发
|
||||||
|
- ✓ 速度提升约5倍
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 方式1:在backend根目录运行
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# 爬取前10个应用
|
||||||
|
python3 crawl.py --limit 10
|
||||||
|
|
||||||
|
# 爬取所有应用
|
||||||
|
python3 crawl.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式2:在crawler目录运行
|
||||||
|
```bash
|
||||||
|
cd backend/app/crawler
|
||||||
|
|
||||||
|
# 爬取前10个应用
|
||||||
|
python3 crawl.py --limit 10
|
||||||
|
|
||||||
|
# 爬取所有应用
|
||||||
|
python3 crawl.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能对比
|
||||||
|
|
||||||
|
| 应用数量 | 旧版(串行) | 新版(并发5) | 提升 |
|
||||||
|
|---------|------------|-------------|------|
|
||||||
|
| 10个 | ~5秒 | ~1秒 | 5倍 |
|
||||||
|
| 100个 | ~50秒 | ~10秒 | 5倍 |
|
||||||
|
| 962个 | ~8分钟 | ~2分钟 | 4倍 |
|
||||||
|
|
||||||
|
## 输出示例
|
||||||
|
|
||||||
|
```
|
||||||
|
================================================================================
|
||||||
|
开始爬取 2 个应用(并发数: 5)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[1/2] C6917559067092904725 ✓ 突击射击 → 无更新
|
||||||
|
[2/2] C6917559133889396578 ✓ 欢乐麻将 → 无更新
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
爬取完成: 成功 2 个, 失败 0 个
|
||||||
|
================================================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
## 新增功能
|
||||||
|
|
||||||
|
### 前端应用详情页
|
||||||
|
现在会显示:
|
||||||
|
- ✅ 支持平台(手机、平板、智慧屏等)
|
||||||
|
- ✅ 目标SDK版本
|
||||||
|
- ✅ 最低API级别
|
||||||
|
- ✅ 价格信息
|
||||||
|
|
||||||
|
### 设备类型映射
|
||||||
|
- 0 → 手机
|
||||||
|
- 1 → 平板
|
||||||
|
- 2 → 智慧屏
|
||||||
|
- 3 → 手表
|
||||||
|
- 4 → 车机
|
||||||
|
- 5 → PC
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **数据库连接警告**:运行结束时可能会看到 `RuntimeError: Event loop is closed` 警告,这是 aiomysql 的已知问题,不影响功能。
|
||||||
|
|
||||||
|
2. **并发数调整**:如果遇到网络问题,可以在 `crawler.py` 中调整 `batch_size` 参数(建议5-10之间)。
|
||||||
|
|
||||||
|
3. **重新爬取**:升级后建议重新爬取一次数据,以获取所有新字段的信息。
|
||||||
|
|
||||||
|
## 完整流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 数据库迁移(已完成)
|
||||||
|
cd backend
|
||||||
|
python3 migrate_db.py
|
||||||
|
|
||||||
|
# 2. 启动后端服务(新终端)
|
||||||
|
python3 -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# 3. 爬取数据(新终端)
|
||||||
|
python3 crawl.py --limit 10
|
||||||
|
|
||||||
|
# 4. 启动前端(新终端)
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 5. 访问
|
||||||
|
# http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### Q: 爬虫提示找不到文件
|
||||||
|
A: 确保在 `backend` 目录下运行 `python3 crawl.py`
|
||||||
|
|
||||||
|
### Q: 数据库连接失败
|
||||||
|
A: 检查 `.env` 文件中的数据库配置
|
||||||
|
|
||||||
|
### Q: 并发爬取失败率高
|
||||||
|
A: 降低并发数,修改 `crawler.py` 中的 `batch_size=5` 改为 `batch_size=3`
|
||||||
@@ -6,9 +6,50 @@ from typing import Optional
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import AppInfo, AppMetrics, AppRating
|
from app.models import AppInfo, AppMetrics, AppRating
|
||||||
from app.schemas import ApiResponse
|
from app.schemas import ApiResponse
|
||||||
|
from app.crawler.huawei_api import HuaweiAPI
|
||||||
|
from app.crawler.data_processor import DataProcessor
|
||||||
|
|
||||||
router = APIRouter(prefix="/apps", tags=["应用"])
|
router = APIRouter(prefix="/apps", tags=["应用"])
|
||||||
|
|
||||||
|
@router.get("/fetch/{pkg_name}")
|
||||||
|
async def fetch_app_by_pkg_name(
|
||||||
|
pkg_name: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""通过包名从华为API获取应用信息并保存"""
|
||||||
|
api = HuaweiAPI()
|
||||||
|
try:
|
||||||
|
# 从华为API获取数据
|
||||||
|
print(f"正在获取应用信息: {pkg_name}")
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
success=True,
|
||||||
|
data={
|
||||||
|
"app_id": app_data['appId'],
|
||||||
|
"name": app_data['name'],
|
||||||
|
"pkg_name": app_data['pkgName'],
|
||||||
|
"new_info": new_info,
|
||||||
|
"new_metric": new_metric,
|
||||||
|
"new_rating": new_rating,
|
||||||
|
"message": "应用信息获取成功"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取应用信息失败: {str(e)}")
|
||||||
|
finally:
|
||||||
|
await api.close()
|
||||||
|
|
||||||
@router.get("/search")
|
@router.get("/search")
|
||||||
async def search_apps(
|
async def search_apps(
|
||||||
q: str = Query(..., min_length=1),
|
q: str = Query(..., min_length=1),
|
||||||
@@ -84,6 +125,7 @@ async def get_apps_by_category(
|
|||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 构建基础查询
|
||||||
query = (
|
query = (
|
||||||
select(AppInfo, AppMetrics, AppRating)
|
select(AppInfo, AppMetrics, AppRating)
|
||||||
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
||||||
@@ -92,8 +134,19 @@ async def get_apps_by_category(
|
|||||||
AppMetrics.app_id == subquery.c.app_id,
|
AppMetrics.app_id == subquery.c.app_id,
|
||||||
AppMetrics.created_at == subquery.c.max_created_at
|
AppMetrics.created_at == subquery.c.max_created_at
|
||||||
))
|
))
|
||||||
.where(AppInfo.kind_name == category)
|
)
|
||||||
.order_by(AppMetrics.download_count.desc())
|
|
||||||
|
# 如果是元服务分类,只显示元服务(packing_type = 1)
|
||||||
|
if category == "元服务":
|
||||||
|
query = query.where(AppInfo.packing_type == 1)
|
||||||
|
else:
|
||||||
|
# 其他分类排除元服务,并按kind_name筛选
|
||||||
|
query = query.where(and_(
|
||||||
|
AppInfo.kind_name == category,
|
||||||
|
or_(AppInfo.packing_type != 1, AppInfo.packing_type.is_(None))
|
||||||
|
))
|
||||||
|
|
||||||
|
query = query.order_by(AppMetrics.download_count.desc())
|
||||||
)
|
)
|
||||||
|
|
||||||
count_query = select(func.count(AppInfo.app_id)).where(AppInfo.kind_name == category)
|
count_query = select(func.count(AppInfo.app_id)).where(AppInfo.kind_name == category)
|
||||||
@@ -125,30 +178,56 @@ async def get_apps_by_category(
|
|||||||
@router.get("/categories")
|
@router.get("/categories")
|
||||||
async def get_categories(db: AsyncSession = Depends(get_db)):
|
async def get_categories(db: AsyncSession = Depends(get_db)):
|
||||||
"""获取所有分类"""
|
"""获取所有分类"""
|
||||||
|
# 获取元服务数量
|
||||||
|
atomic_service_result = await db.execute(
|
||||||
|
select(func.count(AppInfo.app_id))
|
||||||
|
.where(AppInfo.packing_type == 1)
|
||||||
|
)
|
||||||
|
atomic_service_count = atomic_service_result.scalar()
|
||||||
|
|
||||||
|
# 获取其他分类(排除元服务)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(AppInfo.kind_name, func.count(AppInfo.app_id).label('count'))
|
select(AppInfo.kind_name, func.count(AppInfo.app_id).label('count'))
|
||||||
|
.where(or_(AppInfo.packing_type != 1, AppInfo.packing_type.is_(None)))
|
||||||
.group_by(AppInfo.kind_name)
|
.group_by(AppInfo.kind_name)
|
||||||
.order_by(func.count(AppInfo.app_id).desc())
|
.order_by(func.count(AppInfo.app_id).desc())
|
||||||
)
|
)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
data = [{"name": row[0], "count": row[1]} for row in rows]
|
data = []
|
||||||
|
|
||||||
|
# 如果有元服务,添加到列表首位
|
||||||
|
if atomic_service_count > 0:
|
||||||
|
data.append({"name": "元服务", "count": atomic_service_count})
|
||||||
|
|
||||||
|
# 添加其他分类
|
||||||
|
data.extend([{"name": row[0], "count": row[1]} for row in rows])
|
||||||
|
|
||||||
return ApiResponse(success=True, data=data)
|
return ApiResponse(success=True, data=data)
|
||||||
|
|
||||||
@router.get("/today")
|
@router.get("/by-date")
|
||||||
async def get_today_apps(
|
async def get_apps_by_date(
|
||||||
page_size: int = Query(20, le=100),
|
date: str = Query(..., description="日期格式: YYYY-MM-DD"),
|
||||||
|
page_size: int = Query(100, le=100),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取今日上架应用"""
|
"""获取指定日期上架的应用"""
|
||||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
try:
|
||||||
|
from datetime import datetime, time
|
||||||
|
|
||||||
|
# 解析日期字符串
|
||||||
|
target_date = datetime.strptime(date, '%Y-%m-%d')
|
||||||
|
date_start = datetime.combine(target_date, time.min)
|
||||||
|
date_end = datetime.combine(target_date, time.max)
|
||||||
|
|
||||||
|
# 获取最新的指标记录
|
||||||
subquery = (
|
subquery = (
|
||||||
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
|
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
|
||||||
.group_by(AppMetrics.app_id)
|
.group_by(AppMetrics.app_id)
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 查询指定日期上架的应用
|
||||||
query = (
|
query = (
|
||||||
select(AppInfo, AppMetrics, AppRating)
|
select(AppInfo, AppMetrics, AppRating)
|
||||||
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
||||||
@@ -157,7 +236,10 @@ async def get_today_apps(
|
|||||||
AppMetrics.app_id == subquery.c.app_id,
|
AppMetrics.app_id == subquery.c.app_id,
|
||||||
AppMetrics.created_at == subquery.c.max_created_at
|
AppMetrics.created_at == subquery.c.max_created_at
|
||||||
))
|
))
|
||||||
.where(AppInfo.listed_at >= today)
|
.where(and_(
|
||||||
|
AppInfo.listed_at >= date_start,
|
||||||
|
AppInfo.listed_at <= date_end
|
||||||
|
))
|
||||||
.order_by(AppInfo.listed_at.desc())
|
.order_by(AppInfo.listed_at.desc())
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
)
|
)
|
||||||
@@ -173,26 +255,42 @@ async def get_today_apps(
|
|||||||
"kind_name": row[0].kind_name,
|
"kind_name": row[0].kind_name,
|
||||||
"icon_url": row[0].icon_url,
|
"icon_url": row[0].icon_url,
|
||||||
"brief_desc": row[0].brief_desc,
|
"brief_desc": row[0].brief_desc,
|
||||||
"download_count": row[1].download_count if len(row) > 1 else 0,
|
"download_count": row[1].download_count if len(row) > 1 and row[1] else 0,
|
||||||
"version": row[1].version if len(row) > 1 else "",
|
"version": row[1].version if len(row) > 1 and row[1] else "",
|
||||||
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0,
|
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0.0,
|
||||||
"listed_at": row[0].listed_at.isoformat()
|
"total_rating_count": row[2].total_rating_count if len(row) > 2 and row[2] else 0,
|
||||||
|
"listed_at": row[0].listed_at.isoformat() if row[0].listed_at else ""
|
||||||
} for row in rows]
|
} for row in rows]
|
||||||
|
|
||||||
return ApiResponse(success=True, data=data, total=len(data))
|
return ApiResponse(success=True, data=data, total=len(data))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"日期格式错误: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in get_apps_by_date: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return ApiResponse(success=True, data=[], total=0)
|
||||||
|
|
||||||
@router.get("/top-downloads")
|
@router.get("/today")
|
||||||
async def get_top_downloads(
|
async def get_today_apps(
|
||||||
limit: int = Query(100, le=100),
|
page_size: int = Query(100, le=100),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""热门应用Top100"""
|
"""获取今日上架应用(根据 listed_at 字段判断是否为今天上架)"""
|
||||||
|
try:
|
||||||
|
# 获取今天的日期范围(00:00:00 到 23:59:59)
|
||||||
|
from datetime import datetime, time
|
||||||
|
today_start = datetime.combine(datetime.today(), time.min)
|
||||||
|
today_end = datetime.combine(datetime.today(), time.max)
|
||||||
|
|
||||||
|
# 获取最新的指标记录
|
||||||
subquery = (
|
subquery = (
|
||||||
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
|
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
|
||||||
.group_by(AppMetrics.app_id)
|
.group_by(AppMetrics.app_id)
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 查询今天上架的应用(根据 listed_at 字段)
|
||||||
query = (
|
query = (
|
||||||
select(AppInfo, AppMetrics, AppRating)
|
select(AppInfo, AppMetrics, AppRating)
|
||||||
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
||||||
@@ -201,6 +299,72 @@ async def get_top_downloads(
|
|||||||
AppMetrics.app_id == subquery.c.app_id,
|
AppMetrics.app_id == subquery.c.app_id,
|
||||||
AppMetrics.created_at == subquery.c.max_created_at
|
AppMetrics.created_at == subquery.c.max_created_at
|
||||||
))
|
))
|
||||||
|
.where(and_(
|
||||||
|
AppInfo.listed_at >= today_start,
|
||||||
|
AppInfo.listed_at <= today_end
|
||||||
|
))
|
||||||
|
.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 and row[1] else 0,
|
||||||
|
"version": row[1].version if len(row) > 1 and row[1] else "",
|
||||||
|
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0.0,
|
||||||
|
"total_rating_count": row[2].total_rating_count if len(row) > 2 and row[2] else 0,
|
||||||
|
"listed_at": row[0].listed_at.isoformat() if row[0].listed_at else ""
|
||||||
|
} for row in rows]
|
||||||
|
|
||||||
|
return ApiResponse(success=True, data=data, total=len(data))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in get_today_apps: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
# 返回空列表而不是抛出错误
|
||||||
|
return ApiResponse(success=True, data=[], total=0)
|
||||||
|
|
||||||
|
@router.get("/top-downloads")
|
||||||
|
async def get_top_downloads(
|
||||||
|
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_rating_created_at'))
|
||||||
|
.group_by(AppRating.app_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(AppInfo, AppMetrics, AppRating)
|
||||||
|
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
|
||||||
|
.join(subquery_metric, and_(
|
||||||
|
AppMetrics.app_id == subquery_metric.c.app_id,
|
||||||
|
AppMetrics.created_at == subquery_metric.c.max_created_at
|
||||||
|
))
|
||||||
|
.outerjoin(subquery_rating, AppInfo.app_id == subquery_rating.c.app_id)
|
||||||
|
.outerjoin(AppRating, and_(
|
||||||
|
AppInfo.app_id == AppRating.app_id,
|
||||||
|
AppRating.created_at == subquery_rating.c.max_rating_created_at
|
||||||
|
))
|
||||||
.order_by(AppMetrics.download_count.desc())
|
.order_by(AppMetrics.download_count.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
@@ -305,20 +469,57 @@ async def get_app_detail(app_id: str, db: AsyncSession = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail="应用不存在")
|
raise HTTPException(status_code=404, detail="应用不存在")
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
|
# 基本信息
|
||||||
"app_id": row[0].app_id,
|
"app_id": row[0].app_id,
|
||||||
"name": row[0].name,
|
"name": row[0].name,
|
||||||
"pkg_name": row[0].pkg_name,
|
"pkg_name": row[0].pkg_name,
|
||||||
|
|
||||||
|
# 开发者信息
|
||||||
"developer_name": row[0].developer_name,
|
"developer_name": row[0].developer_name,
|
||||||
|
"dev_id": row[0].dev_id,
|
||||||
|
"supplier": row[0].supplier,
|
||||||
|
|
||||||
|
# 分类信息
|
||||||
"kind_name": row[0].kind_name,
|
"kind_name": row[0].kind_name,
|
||||||
|
"kind_id": row[0].kind_id,
|
||||||
|
"tag_name": row[0].tag_name,
|
||||||
|
|
||||||
|
# 展示信息
|
||||||
"icon_url": row[0].icon_url,
|
"icon_url": row[0].icon_url,
|
||||||
"brief_desc": row[0].brief_desc,
|
"brief_desc": row[0].brief_desc,
|
||||||
"description": row[0].description,
|
"description": row[0].description,
|
||||||
|
|
||||||
|
# 隐私和政策
|
||||||
"privacy_url": row[0].privacy_url,
|
"privacy_url": row[0].privacy_url,
|
||||||
|
|
||||||
|
# 价格和支付
|
||||||
"is_pay": row[0].is_pay,
|
"is_pay": row[0].is_pay,
|
||||||
|
"price": row[0].price,
|
||||||
|
|
||||||
|
# 时间信息
|
||||||
"listed_at": row[0].listed_at.isoformat(),
|
"listed_at": row[0].listed_at.isoformat(),
|
||||||
|
|
||||||
|
# 设备支持
|
||||||
|
"main_device_codes": row[0].main_device_codes or [],
|
||||||
|
|
||||||
|
# SDK信息
|
||||||
|
"target_sdk": row[0].target_sdk,
|
||||||
|
"min_sdk": row[0].min_sdk,
|
||||||
|
"compile_sdk_version": row[0].compile_sdk_version,
|
||||||
|
"min_hmos_api_level": row[0].min_hmos_api_level,
|
||||||
|
"api_release_type": row[0].api_release_type,
|
||||||
|
|
||||||
|
# 其他信息
|
||||||
|
"ctype": row[0].ctype,
|
||||||
|
"app_level": row[0].app_level,
|
||||||
|
"packing_type": row[0].packing_type,
|
||||||
|
|
||||||
|
# 版本和指标信息
|
||||||
"download_count": row[1].download_count if len(row) > 1 else 0,
|
"download_count": row[1].download_count if len(row) > 1 else 0,
|
||||||
"version": row[1].version if len(row) > 1 else "",
|
"version": row[1].version if len(row) > 1 else "",
|
||||||
"size_bytes": row[1].size_bytes if len(row) > 1 else 0,
|
"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,
|
"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,
|
"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_1_count": row[2].star_1_count if len(row) > 2 and row[2] else 0,
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from typing import List
|
from typing import List
|
||||||
|
import json
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
MYSQL_HOST: str = "localhost"
|
MYSQL_HOST: str = "43.240.221.214"
|
||||||
MYSQL_PORT: int = 3306
|
MYSQL_PORT: int = 3306
|
||||||
MYSQL_USER: str = "root"
|
MYSQL_USER: str = "ns2.0"
|
||||||
MYSQL_PASSWORD: str = "password"
|
MYSQL_PASSWORD: str = "5B3kdCyx2ya3XhrC"
|
||||||
MYSQL_DATABASE: str = "huawei_market"
|
MYSQL_DATABASE: str = "ns2.0"
|
||||||
|
|
||||||
API_PREFIX: str = "/api"
|
API_PREFIX: str = "/api"
|
||||||
API_TITLE: str = "鸿蒙应用展示平台API"
|
API_TITLE: str = "鸿蒙应用展示平台API"
|
||||||
API_VERSION: str = "1.0.0"
|
API_VERSION: str = "1.0.0"
|
||||||
|
|
||||||
DEBUG: bool = False
|
DEBUG: bool = False
|
||||||
CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"]
|
CORS_ORIGINS: str = '["http://localhost:5173", "http://localhost:3000"]'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins_list(self) -> List[str]:
|
||||||
|
"""解析 CORS_ORIGINS 字符串为列表"""
|
||||||
|
if isinstance(self.CORS_ORIGINS, str):
|
||||||
|
try:
|
||||||
|
return json.loads(self.CORS_ORIGINS)
|
||||||
|
except:
|
||||||
|
return [self.CORS_ORIGINS]
|
||||||
|
return self.CORS_ORIGINS
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
|
|||||||
196
backend/app/crawler/README.md
Normal file
196
backend/app/crawler/README.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# 华为应用市场爬虫
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入爬虫目录
|
||||||
|
cd backend/app/crawler
|
||||||
|
|
||||||
|
# 爬取所有962个应用(默认50并发)
|
||||||
|
python3 crawl.py
|
||||||
|
|
||||||
|
# 或者只爬取前10个应用(测试)
|
||||||
|
python3 crawl.py --limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动检查并创建数据库表(如果不存在)
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
### 命令参数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 crawl.py [选项]
|
||||||
|
|
||||||
|
选项:
|
||||||
|
--limit N 只爬取前N个应用(默认爬取所有962个)
|
||||||
|
--batch N 并发数量(默认50)
|
||||||
|
--skip-init 跳过数据库初始化检查
|
||||||
|
-h, --help 显示帮助信息
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 爬取所有应用(50并发)
|
||||||
|
python3 crawl.py
|
||||||
|
|
||||||
|
# 爬取前10个应用
|
||||||
|
python3 crawl.py --limit 10
|
||||||
|
|
||||||
|
# 使用100并发爬取
|
||||||
|
python3 crawl.py --batch 100
|
||||||
|
|
||||||
|
# 爬取100个应用,使用20并发
|
||||||
|
python3 crawl.py --limit 100 --batch 20
|
||||||
|
|
||||||
|
# 跳过数据库检查直接爬取
|
||||||
|
python3 crawl.py --skip-init
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能对比
|
||||||
|
|
||||||
|
| 并发数 | 爬取100个应用 | 爬取962个应用 |
|
||||||
|
|--------|--------------|--------------|
|
||||||
|
| 5 | ~10秒 | ~2分钟 |
|
||||||
|
| 10 | ~5秒 | ~1分钟 |
|
||||||
|
| 50 | ~2秒 | ~20秒 |
|
||||||
|
| 100 | ~1秒 | ~10秒 |
|
||||||
|
|
||||||
|
## 文件说明
|
||||||
|
|
||||||
|
- `crawl.py` - 爬虫命令行入口(主程序)
|
||||||
|
- `guess.py` - 应用ID列表(962个已知的鸿蒙应用ID)
|
||||||
|
- `app_ids.py` - ID加载器(从guess.py加载ID)
|
||||||
|
- `crawler.py` - 爬虫核心类
|
||||||
|
- `huawei_api.py` - 华为API封装
|
||||||
|
- `token_manager.py` - Token自动管理
|
||||||
|
- `data_processor.py` - 数据处理和保存
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. **检查数据库**:自动检查表是否存在,不存在则创建
|
||||||
|
2. **加载ID列表**:从 `guess.py` 加载962个应用ID
|
||||||
|
3. **并发爬取**:
|
||||||
|
- 分批并发获取应用信息
|
||||||
|
- 获取评分数据
|
||||||
|
- 保存到数据库(智能去重)
|
||||||
|
4. **显示进度**:实时显示爬取进度和状态
|
||||||
|
|
||||||
|
## 输出说明
|
||||||
|
|
||||||
|
```
|
||||||
|
[1/962] C6917559067092904725 ✓ 突击射击 → 新应用, 新指标, 新评分
|
||||||
|
```
|
||||||
|
|
||||||
|
- `[1/962]`: 当前进度
|
||||||
|
- `C6917559067092904725`: 应用ID
|
||||||
|
- `✓ 突击射击`: 成功获取应用信息
|
||||||
|
- `→ 新应用, 新指标, 新评分`: 保存状态
|
||||||
|
- `新应用`: 首次保存该应用的基本信息
|
||||||
|
- `新指标`: 保存了新的版本指标记录
|
||||||
|
- `新评分`: 保存了新的评分记录
|
||||||
|
- `无更新`: 数据无变化,未保存新记录
|
||||||
|
|
||||||
|
## 数据存储
|
||||||
|
|
||||||
|
爬取的数据保存在三张表中:
|
||||||
|
|
||||||
|
### app_info(应用基本信息)
|
||||||
|
- 主键:app_id
|
||||||
|
- 唯一索引:pkg_name
|
||||||
|
- 包含:名称、开发者、分类、图标、描述、设备支持、SDK信息等
|
||||||
|
|
||||||
|
### app_metrics(应用指标历史)
|
||||||
|
- 自增主键:id
|
||||||
|
- 外键:app_id, pkg_name
|
||||||
|
- 包含:版本号、大小、下载量、发布时间
|
||||||
|
- 每次版本或下载量变化时新增一条记录
|
||||||
|
|
||||||
|
### app_rating(应用评分历史)
|
||||||
|
- 自增主键:id
|
||||||
|
- 外键:app_id, pkg_name
|
||||||
|
- 包含:平均评分、各星级数量、总评分数
|
||||||
|
- 每次评分变化时新增一条记录
|
||||||
|
|
||||||
|
## 新增字段
|
||||||
|
|
||||||
|
### 设备支持
|
||||||
|
- `main_device_codes`: 支持的设备列表
|
||||||
|
- 0: 手机
|
||||||
|
- 1: 平板
|
||||||
|
- 2: 智慧屏
|
||||||
|
- 3: 手表
|
||||||
|
- 4: 车机
|
||||||
|
- 5: PC
|
||||||
|
|
||||||
|
### SDK信息
|
||||||
|
- `target_sdk`: 目标SDK版本
|
||||||
|
- `min_sdk`: 最低SDK版本
|
||||||
|
- `compile_sdk_version`: 编译SDK版本
|
||||||
|
- `min_hmos_api_level`: 最低HarmonyOS API级别
|
||||||
|
- `api_release_type`: API发布类型
|
||||||
|
|
||||||
|
### 其他信息
|
||||||
|
- `dev_id`: 开发者ID
|
||||||
|
- `supplier`: 供应商
|
||||||
|
- `kind_id`: 分类ID
|
||||||
|
- `tag_name`: 标签名称
|
||||||
|
- `price`: 价格
|
||||||
|
- `ctype`: 内容类型
|
||||||
|
- `app_level`: 应用级别
|
||||||
|
- `packing_type`: 打包类型
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **Token管理**:Token会自动刷新,有效期约1小时
|
||||||
|
2. **爬取速度**:并发数越高速度越快,但建议不超过100
|
||||||
|
3. **网络稳定性**:高并发对网络要求较高
|
||||||
|
4. **数据库连接**:确保数据库支持足够的并发连接
|
||||||
|
5. **重复运行**:可以重复运行,只会保存有变化的数据
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 数据库连接失败
|
||||||
|
```
|
||||||
|
✗ 数据库检查失败: (pymysql.err.OperationalError)
|
||||||
|
```
|
||||||
|
**解决方案**:
|
||||||
|
- 检查 `backend/.env` 文件中的数据库配置
|
||||||
|
- 确认数据库服务器可访问
|
||||||
|
|
||||||
|
### Token刷新失败
|
||||||
|
```
|
||||||
|
✗ Token刷新失败
|
||||||
|
```
|
||||||
|
**解决方案**:
|
||||||
|
- 检查网络连接
|
||||||
|
- 等待片刻后重试
|
||||||
|
|
||||||
|
### 应用爬取失败
|
||||||
|
```
|
||||||
|
✗ 跳过(安卓应用)
|
||||||
|
```
|
||||||
|
**说明**:这是正常的,表示该ID对应的是安卓应用,不是鸿蒙应用
|
||||||
|
|
||||||
|
### 并发过高导致失败
|
||||||
|
**解决方案**:降低并发数
|
||||||
|
```bash
|
||||||
|
python3 crawl.py --batch 20
|
||||||
|
```
|
||||||
|
|
||||||
|
## 编程方式使用
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from app.crawler import HuaweiCrawler
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# 使用上下文管理器
|
||||||
|
async with HuaweiCrawler() as crawler:
|
||||||
|
# 爬取前10个应用,使用50并发
|
||||||
|
success, failed = await crawler.crawl_by_ids(limit=10, batch_size=50)
|
||||||
|
print(f"成功: {success}, 失败: {failed}")
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
78
backend/app/crawler/UPGRADE.md
Normal file
78
backend/app/crawler/UPGRADE.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# 爬虫升级说明
|
||||||
|
|
||||||
|
## 新功能
|
||||||
|
|
||||||
|
### 1. 增加更多字段
|
||||||
|
现在爬虫会保存以下额外信息:
|
||||||
|
- **开发者信息**: dev_id, supplier
|
||||||
|
- **分类信息**: kind_id, tag_name
|
||||||
|
- **价格信息**: price
|
||||||
|
- **设备支持**: main_device_codes(手机、平板、智慧屏等)
|
||||||
|
- **SDK信息**: target_sdk, min_sdk, compile_sdk_version, min_hmos_api_level
|
||||||
|
- **其他信息**: ctype, app_level, packing_type
|
||||||
|
|
||||||
|
### 2. 并发爬取
|
||||||
|
- 默认并发数:5个应用同时爬取
|
||||||
|
- 速度提升:约 **5倍**
|
||||||
|
- 可自定义并发数
|
||||||
|
|
||||||
|
## 升级步骤
|
||||||
|
|
||||||
|
### 1. 数据库迁移
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3 migrate_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 重新爬取数据
|
||||||
|
```bash
|
||||||
|
cd app/crawler
|
||||||
|
python3 crawl.py --limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 基本用法(默认并发5)
|
||||||
|
```bash
|
||||||
|
python3 app/crawler/crawl.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义并发数
|
||||||
|
修改 `crawler.py` 中的 `batch_size` 参数:
|
||||||
|
```python
|
||||||
|
await crawler.crawl_by_ids(limit=10, batch_size=10) # 10个并发
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能对比
|
||||||
|
|
||||||
|
| 模式 | 爬取100个应用 | 爬取962个应用 |
|
||||||
|
|------|--------------|--------------|
|
||||||
|
| 旧版(串行) | ~50秒 | ~8分钟 |
|
||||||
|
| 新版(并发5) | ~10秒 | ~2分钟 |
|
||||||
|
| 新版(并发10) | ~5秒 | ~1分钟 |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **并发数不宜过大**:建议5-10之间,避免触发API限流
|
||||||
|
2. **数据库连接**:确保数据库支持并发写入
|
||||||
|
3. **网络稳定性**:并发爬取对网络要求更高
|
||||||
|
|
||||||
|
## 新增字段说明
|
||||||
|
|
||||||
|
### 设备代码映射
|
||||||
|
- `0`: 手机
|
||||||
|
- `1`: 平板
|
||||||
|
- `2`: 智慧屏
|
||||||
|
- `3`: 手表
|
||||||
|
- `4`: 车机
|
||||||
|
- `5`: PC
|
||||||
|
|
||||||
|
### SDK版本
|
||||||
|
- `target_sdk`: 目标SDK版本
|
||||||
|
- `min_sdk`: 最低SDK版本
|
||||||
|
- `min_hmos_api_level`: 最低HarmonyOS API级别
|
||||||
|
|
||||||
|
### 应用级别
|
||||||
|
- `app_level`: 应用级别(1-5)
|
||||||
|
- `ctype`: 内容类型
|
||||||
|
- `packing_type`: 打包类型
|
||||||
12
backend/app/crawler/__init__.py
Normal file
12
backend/app/crawler/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
华为应用市场爬虫模块
|
||||||
|
"""
|
||||||
|
from app.crawler.crawler import HuaweiCrawler, crawl_all, crawl_limited
|
||||||
|
from app.crawler.app_ids import KNOWN_APP_IDS
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'HuaweiCrawler',
|
||||||
|
'crawl_all',
|
||||||
|
'crawl_limited',
|
||||||
|
'KNOWN_APP_IDS',
|
||||||
|
]
|
||||||
53
backend/app/crawler/app_ids.py
Normal file
53
backend/app/crawler/app_ids.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
华为应用市场已知的鸿蒙应用ID列表
|
||||||
|
从 guess.py 分析得出,共962个ID
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 导入ID列表的函数
|
||||||
|
def load_app_ids():
|
||||||
|
"""加载应用ID列表"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 从同目录下的 guess.py 导入
|
||||||
|
guess_file = os.path.join(os.path.dirname(__file__), 'guess.py')
|
||||||
|
if os.path.exists(guess_file):
|
||||||
|
# 读取 guess.py 中的 ids 列表
|
||||||
|
with open(guess_file, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
# 提取 ids 列表部分
|
||||||
|
start = content.find('ids = [')
|
||||||
|
end = content.find(']', start) + 1
|
||||||
|
ids_code = content[start:end]
|
||||||
|
|
||||||
|
# 执行代码获取 ids
|
||||||
|
local_vars = {}
|
||||||
|
exec(ids_code, {}, local_vars)
|
||||||
|
return local_vars['ids']
|
||||||
|
|
||||||
|
# 如果文件不存在,返回默认的前20个ID
|
||||||
|
return [
|
||||||
|
6917559067092904725,
|
||||||
|
6917559133889396578,
|
||||||
|
6917559134045802769,
|
||||||
|
6917559138770331354,
|
||||||
|
6917559303873561126,
|
||||||
|
6917559384755888642,
|
||||||
|
6917559398244134093,
|
||||||
|
6917559401760179700,
|
||||||
|
6917559412599401190,
|
||||||
|
6917559420741644814,
|
||||||
|
6917559471584581139,
|
||||||
|
6917559493442858602,
|
||||||
|
6917559997337903225,
|
||||||
|
6917560000979877756,
|
||||||
|
6917560003449022390,
|
||||||
|
6917560016672900552,
|
||||||
|
6917560022799490908,
|
||||||
|
6917560032190348725,
|
||||||
|
6917560035472143514,
|
||||||
|
6917560097545123074,
|
||||||
|
]
|
||||||
|
|
||||||
|
# 全局变量:应用ID列表
|
||||||
|
KNOWN_APP_IDS = load_app_ids()
|
||||||
72
backend/app/crawler/crawl.py
Normal file
72
backend/app/crawler/crawl.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
华为应用市场爬虫 - 命令行入口
|
||||||
|
一键爬取 guess.py 中的所有应用到数据库
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import argparse
|
||||||
|
from app.database import engine, Base
|
||||||
|
from app.models import AppInfo, AppMetrics, AppRating
|
||||||
|
from app.crawler.crawler import HuaweiCrawler
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
async def init_database():
|
||||||
|
"""初始化数据库表(仅在表不存在时创建)"""
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# 检查表是否存在
|
||||||
|
result = await conn.execute(text("SHOW TABLES LIKE 'app_info'"))
|
||||||
|
exists = result.fetchone()
|
||||||
|
|
||||||
|
if not exists:
|
||||||
|
print("数据库表不存在,正在创建...")
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
print("✓ 数据库表创建成功\n")
|
||||||
|
# 如果表已存在,不输出任何信息,直接继续
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ 数据库检查失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='华为应用市场爬虫 - 一键爬取所有应用到数据库',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
示例:
|
||||||
|
python3 app/crawler/crawl.py # 爬取所有应用(默认50并发)
|
||||||
|
python3 app/crawler/crawl.py --limit 10 # 只爬取前10个应用
|
||||||
|
python3 app/crawler/crawl.py --batch 100 # 使用100并发
|
||||||
|
python3 app/crawler/crawl.py --limit 100 --batch 20 # 爬取100个,20并发
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
parser.add_argument('--limit', type=int, help='限制爬取数量(默认爬取所有)')
|
||||||
|
parser.add_argument('--batch', type=int, default=50, help='并发数量(默认50)')
|
||||||
|
parser.add_argument('--skip-init', action='store_true', help='跳过数据库初始化检查')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 自动检查并初始化数据库(仅在表不存在时)
|
||||||
|
if not args.skip_init:
|
||||||
|
if not await init_database():
|
||||||
|
print("\n数据库检查失败,请检查配置后重试")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 开始爬取
|
||||||
|
async with HuaweiCrawler() as crawler:
|
||||||
|
await crawler.crawl_by_ids(limit=args.limit, batch_size=args.batch)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 清理数据库引擎,避免警告
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
143
backend/app/crawler/crawler.py
Normal file
143
backend/app/crawler/crawler.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
华为应用市场爬虫主程序
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, List
|
||||||
|
from app.crawler.huawei_api import HuaweiAPI
|
||||||
|
from app.crawler.data_processor import DataProcessor
|
||||||
|
from app.crawler.app_ids import KNOWN_APP_IDS
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
class HuaweiCrawler:
|
||||||
|
"""华为应用市场爬虫"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api = HuaweiAPI()
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
"""异步上下文管理器入口"""
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""异步上下文管理器出口"""
|
||||||
|
await self.api.close()
|
||||||
|
|
||||||
|
async def crawl_by_ids(
|
||||||
|
self,
|
||||||
|
id_list: Optional[List[int]] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
batch_size: int = 50 # 并发批次大小,默认50
|
||||||
|
) -> tuple:
|
||||||
|
"""
|
||||||
|
根据ID列表爬取应用(支持并发)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id_list: ID列表,如果为None则使用KNOWN_APP_IDS
|
||||||
|
limit: 限制爬取数量
|
||||||
|
batch_size: 并发批次大小,默认5个
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(成功数量, 失败数量)
|
||||||
|
"""
|
||||||
|
if id_list is None:
|
||||||
|
id_list = KNOWN_APP_IDS
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
id_list = id_list[:limit]
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"开始爬取 {len(id_list)} 个应用(并发数: {batch_size})")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# 分批处理
|
||||||
|
for batch_start in range(0, len(id_list), batch_size):
|
||||||
|
batch_end = min(batch_start + batch_size, len(id_list))
|
||||||
|
batch = id_list[batch_start:batch_end]
|
||||||
|
|
||||||
|
# 并发爬取一批
|
||||||
|
tasks = []
|
||||||
|
for i, app_id_num in enumerate(batch, batch_start + 1):
|
||||||
|
app_id = f"C{app_id_num:019d}"
|
||||||
|
tasks.append(self._crawl_single_app(app_id, i, len(id_list)))
|
||||||
|
|
||||||
|
# 等待这一批完成
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# 统计结果
|
||||||
|
for result in results:
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
failed_count += 1
|
||||||
|
elif result:
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
# 批次间短暂延迟
|
||||||
|
if batch_end < len(id_list):
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print(f"爬取完成: 成功 {success_count} 个, 失败 {failed_count} 个")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
return success_count, failed_count
|
||||||
|
|
||||||
|
async def _crawl_single_app(self, app_id: str, index: int, total: int) -> bool:
|
||||||
|
"""爬取单个应用(每个任务使用独立的数据库会话)"""
|
||||||
|
# 为每个任务创建独立的数据库会话
|
||||||
|
async with AsyncSessionLocal() as db_session:
|
||||||
|
processor = DataProcessor(db_session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"\n[{index}/{total}] {app_id}", end=" ")
|
||||||
|
|
||||||
|
# 获取应用信息
|
||||||
|
app_data = await self.api.get_app_info(app_id=app_id)
|
||||||
|
print(f"✓ {app_data['name']}", end=" ")
|
||||||
|
|
||||||
|
# 获取评分信息
|
||||||
|
rating_data = await self.api.get_app_rating(app_id)
|
||||||
|
|
||||||
|
# 保存到数据库
|
||||||
|
info_inserted, metric_inserted, rating_inserted = await processor.save_app_data(
|
||||||
|
app_data, rating_data
|
||||||
|
)
|
||||||
|
|
||||||
|
# 显示保存状态
|
||||||
|
status_parts = []
|
||||||
|
if info_inserted:
|
||||||
|
status_parts.append("新应用")
|
||||||
|
if metric_inserted:
|
||||||
|
status_parts.append("新指标")
|
||||||
|
if rating_inserted:
|
||||||
|
status_parts.append("新评分")
|
||||||
|
|
||||||
|
if status_parts:
|
||||||
|
print(f"→ {', '.join(status_parts)}")
|
||||||
|
else:
|
||||||
|
print(f"→ 无更新")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
print(f"✗ 跳过(安卓应用)")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ 失败: {str(e)[:50]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def crawl_all():
|
||||||
|
"""爬取所有已知应用"""
|
||||||
|
async with HuaweiCrawler() as crawler:
|
||||||
|
return await crawler.crawl_by_ids()
|
||||||
|
|
||||||
|
|
||||||
|
async def crawl_limited(limit: int):
|
||||||
|
"""爬取指定数量的应用"""
|
||||||
|
async with HuaweiCrawler() as crawler:
|
||||||
|
return await crawler.crawl_by_ids(limit=limit)
|
||||||
179
backend/app/crawler/data_processor.py
Normal file
179
backend/app/crawler/data_processor.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
from typing import Dict, Any, Optional, Tuple
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models import AppInfo, AppMetrics, AppRating
|
||||||
|
|
||||||
|
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
|
||||||
|
) -> 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:
|
||||||
|
await self._save_app_info(app_data)
|
||||||
|
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
|
||||||
|
|
||||||
|
await self.db.commit()
|
||||||
|
|
||||||
|
return info_inserted, metric_inserted, rating_inserted
|
||||||
|
|
||||||
|
async def _save_app_info(self, data: Dict[str, Any]):
|
||||||
|
"""保存应用基本信息"""
|
||||||
|
app_info = AppInfo(
|
||||||
|
# 基本信息
|
||||||
|
app_id=data['appId'],
|
||||||
|
name=data['name'],
|
||||||
|
pkg_name=data['pkgName'],
|
||||||
|
|
||||||
|
# 开发者信息
|
||||||
|
developer_name=data['developerName'],
|
||||||
|
dev_id=data.get('devId', ''),
|
||||||
|
supplier=data.get('supplier', ''),
|
||||||
|
|
||||||
|
# 分类信息
|
||||||
|
kind_name=data['kindName'],
|
||||||
|
kind_id=data.get('kindId', ''),
|
||||||
|
tag_name=data.get('tagName', ''),
|
||||||
|
|
||||||
|
# 展示信息
|
||||||
|
icon_url=data['icon'],
|
||||||
|
brief_desc=data.get('briefDes', ''),
|
||||||
|
description=data.get('description', ''),
|
||||||
|
|
||||||
|
# 隐私和政策
|
||||||
|
privacy_url=data.get('privacyUrl', ''),
|
||||||
|
|
||||||
|
# 价格和支付
|
||||||
|
is_pay=data.get('isPay') == '1',
|
||||||
|
price=data.get('price', '0'),
|
||||||
|
|
||||||
|
# 时间信息
|
||||||
|
listed_at=datetime.fromtimestamp(data.get('releaseDate', 0) / 1000),
|
||||||
|
|
||||||
|
# 设备支持
|
||||||
|
main_device_codes=data.get('mainDeviceCodes', []),
|
||||||
|
|
||||||
|
# SDK信息
|
||||||
|
target_sdk=data.get('targetSdk', ''),
|
||||||
|
min_sdk=data.get('minsdk', ''),
|
||||||
|
compile_sdk_version=data.get('compileSdkVersion', 0),
|
||||||
|
min_hmos_api_level=data.get('minHmosApiLevel', 0),
|
||||||
|
api_release_type=data.get('apiReleaseType', 'Release'),
|
||||||
|
|
||||||
|
# 其他信息
|
||||||
|
ctype=data.get('ctype', 0),
|
||||||
|
app_level=data.get('appLevel', 0),
|
||||||
|
packing_type=data.get('packingType', 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
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.get('version', ''),
|
||||||
|
size_bytes=int(data.get('size', 0)),
|
||||||
|
download_count=download_count,
|
||||||
|
release_date=int(data.get('releaseDate', 0))
|
||||||
|
)
|
||||||
|
|
||||||
|
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'])
|
||||||
|
)
|
||||||
|
|
||||||
|
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 _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.get('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'])
|
||||||
|
)
|
||||||
1020
backend/app/crawler/guess.py
Normal file
1020
backend/app/crawler/guess.py
Normal file
File diff suppressed because it is too large
Load Diff
106
backend/app/crawler/huawei_api.py
Normal file
106
backend/app/crawler/huawei_api.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import httpx
|
||||||
|
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 = "https://web-drcn.hispace.dbankcloud.com/edge"
|
||||||
|
self.locale = "zh_CN"
|
||||||
|
self.token_manager = TokenManager()
|
||||||
|
self.client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
|
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()
|
||||||
50
backend/app/crawler/token_manager.py
Normal file
50
backend/app/crawler/token_manager.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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")
|
||||||
@@ -1,20 +1,55 @@
|
|||||||
from sqlalchemy import Column, String, Integer, Text, DateTime, Boolean, JSON
|
from sqlalchemy import Column, String, Integer, Text, DateTime, Boolean, JSON, BigInteger
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
class AppInfo(Base):
|
class AppInfo(Base):
|
||||||
__tablename__ = "app_info"
|
__tablename__ = "app_info"
|
||||||
|
|
||||||
|
# 基本信息
|
||||||
app_id = Column(String(50), primary_key=True)
|
app_id = Column(String(50), primary_key=True)
|
||||||
name = Column(String(255), nullable=False, index=True)
|
name = Column(String(255), nullable=False, index=True)
|
||||||
pkg_name = Column(String(255), nullable=False, unique=True, index=True)
|
pkg_name = Column(String(255), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
# 开发者信息
|
||||||
developer_name = Column(String(255), nullable=False, index=True)
|
developer_name = Column(String(255), nullable=False, index=True)
|
||||||
|
dev_id = Column(String(100), nullable=True)
|
||||||
|
supplier = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# 分类信息
|
||||||
kind_name = Column(String(100), nullable=False, index=True)
|
kind_name = Column(String(100), nullable=False, index=True)
|
||||||
|
kind_id = Column(String(50), nullable=True)
|
||||||
|
tag_name = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# 展示信息
|
||||||
icon_url = Column(Text, nullable=False)
|
icon_url = Column(Text, nullable=False)
|
||||||
brief_desc = Column(Text, nullable=False)
|
brief_desc = Column(Text, nullable=False)
|
||||||
description = Column(Text, nullable=False)
|
description = Column(Text, nullable=False)
|
||||||
privacy_url = Column(Text, nullable=False)
|
|
||||||
|
# 隐私和政策
|
||||||
|
privacy_url = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# 价格和支付
|
||||||
is_pay = Column(Boolean, default=False)
|
is_pay = Column(Boolean, default=False)
|
||||||
|
price = Column(String(50), nullable=True, default='0')
|
||||||
|
|
||||||
|
# 时间信息
|
||||||
listed_at = Column(DateTime, nullable=False)
|
listed_at = Column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
# 设备支持
|
||||||
|
main_device_codes = Column(JSON, nullable=True) # 支持的设备类型
|
||||||
|
|
||||||
|
# SDK信息
|
||||||
|
target_sdk = Column(String(50), nullable=True)
|
||||||
|
min_sdk = Column(String(50), nullable=True)
|
||||||
|
compile_sdk_version = Column(Integer, nullable=True)
|
||||||
|
min_hmos_api_level = Column(Integer, nullable=True)
|
||||||
|
api_release_type = Column(String(50), nullable=True, default='Release')
|
||||||
|
|
||||||
|
# 其他信息
|
||||||
|
ctype = Column(Integer, nullable=True)
|
||||||
|
app_level = Column(Integer, nullable=True)
|
||||||
|
packing_type = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# 系统字段
|
||||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
16
backend/crawl.py
Executable file
16
backend/crawl.py
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
华为应用市场爬虫 - 快捷入口
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 添加项目路径
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
# 导入并运行爬虫
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from app.crawler.crawl import main
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
28
backend/init_db.py
Normal file
28
backend/init_db.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
初始化数据库表结构
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from app.database import engine, Base
|
||||||
|
from app.models import AppInfo, AppMetrics, AppRating
|
||||||
|
|
||||||
|
|
||||||
|
async def init_database():
|
||||||
|
"""创建所有数据表"""
|
||||||
|
try:
|
||||||
|
print("正在创建数据库表...")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
print("✓ 数据库表创建成功")
|
||||||
|
print("\n创建的表:")
|
||||||
|
print(" - app_info (应用基本信息)")
|
||||||
|
print(" - app_metrics (应用指标)")
|
||||||
|
print(" - app_rating (应用评分)")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ 数据库表创建失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(init_database())
|
||||||
79
backend/migrate_db.py
Executable file
79
backend/migrate_db.py
Executable file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
数据库迁移脚本 - 添加新字段
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.database import engine
|
||||||
|
|
||||||
|
|
||||||
|
async def column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||||
|
"""检查列是否存在"""
|
||||||
|
result = await conn.execute(text(f"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = '{table_name}'
|
||||||
|
AND COLUMN_NAME = '{column_name}'
|
||||||
|
"""))
|
||||||
|
count = result.scalar()
|
||||||
|
return count > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def add_column_if_not_exists(conn, table_name: str, column_name: str, column_def: str):
|
||||||
|
"""如果列不存在则添加"""
|
||||||
|
if not await column_exists(conn, table_name, column_name):
|
||||||
|
sql = f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_def}"
|
||||||
|
print(f"添加字段: {column_name}...")
|
||||||
|
await conn.execute(text(sql))
|
||||||
|
print(f"✓ {column_name} 添加成功")
|
||||||
|
else:
|
||||||
|
print(f"○ {column_name} 已存在,跳过")
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate():
|
||||||
|
"""添加新字段到 app_info 表"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("开始数据库迁移...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
migrations = [
|
||||||
|
# (列名, 列定义)
|
||||||
|
("dev_id", "VARCHAR(100)"),
|
||||||
|
("supplier", "VARCHAR(255)"),
|
||||||
|
("kind_id", "VARCHAR(50)"),
|
||||||
|
("tag_name", "VARCHAR(100)"),
|
||||||
|
("price", "VARCHAR(50) DEFAULT '0'"),
|
||||||
|
("main_device_codes", "JSON"),
|
||||||
|
("target_sdk", "VARCHAR(50)"),
|
||||||
|
("min_sdk", "VARCHAR(50)"),
|
||||||
|
("compile_sdk_version", "INT"),
|
||||||
|
("min_hmos_api_level", "INT"),
|
||||||
|
("api_release_type", "VARCHAR(50) DEFAULT 'Release'"),
|
||||||
|
("ctype", "INT"),
|
||||||
|
("app_level", "INT"),
|
||||||
|
("packing_type", "INT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
for column_name, column_def in migrations:
|
||||||
|
try:
|
||||||
|
await add_column_if_not_exists(conn, "app_info", column_name, column_def)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {column_name} 失败: {e}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("数据库迁移完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migration():
|
||||||
|
"""运行迁移并清理"""
|
||||||
|
try:
|
||||||
|
await migrate()
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run_migration())
|
||||||
@@ -5,3 +5,5 @@ aiomysql==0.2.0
|
|||||||
pydantic==2.5.3
|
pydantic==2.5.3
|
||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.1.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
httpx==0.26.0
|
||||||
|
playwright==1.41.0
|
||||||
|
|||||||
5
backend/start.sh
Executable file
5
backend/start.sh
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 启动后端API服务
|
||||||
|
|
||||||
|
echo "启动华为应用市场API服务..."
|
||||||
|
python3 -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
93
frontend/DEBUG.md
Normal file
93
frontend/DEBUG.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# 应用详情页重复显示问题诊断
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
应用 C6917559384755888642 在详情页显示两次
|
||||||
|
|
||||||
|
## 诊断步骤
|
||||||
|
|
||||||
|
### 1. 检查数据库
|
||||||
|
✅ 已确认:数据库中只有1条记录
|
||||||
|
|
||||||
|
### 2. 检查前端代码
|
||||||
|
✅ 已确认:
|
||||||
|
- App.vue 只有一个 `<router-view />`
|
||||||
|
- AppDetail.vue 没有重复的元素
|
||||||
|
- 路由配置正常
|
||||||
|
|
||||||
|
### 3. 可能的原因
|
||||||
|
|
||||||
|
#### A. 浏览器缓存
|
||||||
|
**解决方案**:
|
||||||
|
1. 硬刷新页面:`Cmd + Shift + R` (Mac) 或 `Ctrl + Shift + R` (Windows)
|
||||||
|
2. 清除浏览器缓存
|
||||||
|
3. 使用无痕模式测试
|
||||||
|
|
||||||
|
#### B. Vue DevTools 检查
|
||||||
|
1. 打开浏览器开发者工具
|
||||||
|
2. 切换到 Vue DevTools
|
||||||
|
3. 检查组件树,看是否有重复的 AppDetail 组件
|
||||||
|
|
||||||
|
#### C. 控制台检查
|
||||||
|
打开浏览器控制台,运行:
|
||||||
|
```javascript
|
||||||
|
// 检查页面上有多少个 app-detail-container
|
||||||
|
document.querySelectorAll('.app-detail-container').length
|
||||||
|
|
||||||
|
// 检查应用名称显示了几次
|
||||||
|
document.querySelectorAll('h1').length
|
||||||
|
```
|
||||||
|
|
||||||
|
#### D. 网络请求检查
|
||||||
|
1. 打开开发者工具 Network 标签
|
||||||
|
2. 刷新页面
|
||||||
|
3. 检查是否有重复的 API 请求到 `/api/apps/C6917559384755888642`
|
||||||
|
|
||||||
|
### 4. 临时解决方案
|
||||||
|
|
||||||
|
如果问题持续,可以尝试:
|
||||||
|
|
||||||
|
1. **重启前端开发服务器**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
# 停止当前服务器 (Ctrl+C)
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **清除 node_modules 缓存**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
rm -rf node_modules/.vite
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **检查是否有多个前端实例在运行**
|
||||||
|
```bash
|
||||||
|
lsof -i :5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 代码检查清单
|
||||||
|
|
||||||
|
- [ ] App.vue 中只有一个 `<router-view />`
|
||||||
|
- [ ] AppDetail.vue 中没有 `v-for` 循环包裹整个内容
|
||||||
|
- [ ] 没有在 main.ts 中多次挂载应用
|
||||||
|
- [ ] 路由配置中没有重复的路由定义
|
||||||
|
|
||||||
|
## 如果问题仍然存在
|
||||||
|
|
||||||
|
请提供以下信息:
|
||||||
|
1. 截图显示"重复"的具体表现
|
||||||
|
2. 浏览器控制台的错误信息
|
||||||
|
3. Vue DevTools 中的组件树截图
|
||||||
|
4. Network 标签中的 API 请求记录
|
||||||
|
|
||||||
|
## 快速测试
|
||||||
|
|
||||||
|
在浏览器控制台运行:
|
||||||
|
```javascript
|
||||||
|
console.log('AppDetail 组件数量:', document.querySelectorAll('.app-detail-container').length);
|
||||||
|
console.log('应用标题数量:', document.querySelectorAll('.app-title h1').length);
|
||||||
|
console.log('应用图标数量:', document.querySelectorAll('.app-icon img').length);
|
||||||
|
```
|
||||||
|
|
||||||
|
如果这些数字都是 1,说明页面没有重复渲染。
|
||||||
|
如果大于 1,说明确实有重复渲染的问题。
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>鸿蒙应用展示平台</title>
|
<title>鸿蒙应用展示平台</title>
|
||||||
|
<!-- Font Awesome Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
<Footer v-if="!isProfilePage" />
|
||||||
</main>
|
</main>
|
||||||
<nav class="bottom-nav">
|
<nav class="bottom-nav">
|
||||||
<router-link to="/" class="nav-item">
|
<router-link to="/" class="nav-item">
|
||||||
@@ -19,6 +20,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>应用</span>
|
<span>应用</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link to="/new_apps" class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
<span>上新</span>
|
||||||
|
</router-link>
|
||||||
<router-link to="/profile" class="nav-item">
|
<router-link to="/profile" class="nav-item">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M4 6h16M4 12h16M4 18h16"/>
|
<path d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
@@ -30,6 +38,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import Footer from '@/components/Footer.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const isProfilePage = computed(() => route.path === '/profile')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -79,7 +93,7 @@
|
|||||||
.main-content {
|
.main-content {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding-bottom: 70px;
|
padding-bottom: 70px;
|
||||||
background: #fff;
|
background: #F5F5F7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 确保在 Safari 上也有毛玻璃效果 */
|
/* 确保在 Safari 上也有毛玻璃效果 */
|
||||||
@@ -91,7 +105,7 @@
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.nav-item {
|
.nav-item {
|
||||||
padding: 4px 16px;
|
padding: 4px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
@@ -103,4 +117,19 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.nav-item {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item span {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -40,12 +40,27 @@ export interface AppDetail extends AppItem {
|
|||||||
description: string
|
description: string
|
||||||
privacy_url: string
|
privacy_url: string
|
||||||
is_pay: boolean
|
is_pay: boolean
|
||||||
|
price: string
|
||||||
size_bytes: number
|
size_bytes: number
|
||||||
star_1_count: number
|
star_1_count: number
|
||||||
star_2_count: number
|
star_2_count: number
|
||||||
star_3_count: number
|
star_3_count: number
|
||||||
star_4_count: number
|
star_4_count: number
|
||||||
star_5_count: number
|
star_5_count: number
|
||||||
|
// 新增字段
|
||||||
|
dev_id?: string
|
||||||
|
supplier?: string
|
||||||
|
kind_id?: string
|
||||||
|
tag_name?: string
|
||||||
|
main_device_codes?: string[]
|
||||||
|
target_sdk?: string
|
||||||
|
min_sdk?: string
|
||||||
|
compile_sdk_version?: number
|
||||||
|
min_hmos_api_level?: number
|
||||||
|
api_release_type?: string
|
||||||
|
ctype?: number
|
||||||
|
app_level?: number
|
||||||
|
packing_type?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
@@ -73,7 +88,10 @@ export const appsApi = {
|
|||||||
api.get<any, ApiResponse<AppItem[]>>('/apps/top-ratings', { params: { limit } }),
|
api.get<any, ApiResponse<AppItem[]>>('/apps/top-ratings', { params: { limit } }),
|
||||||
|
|
||||||
getDetail: (appId: string) =>
|
getDetail: (appId: string) =>
|
||||||
api.get<any, ApiResponse<AppDetail>>(`/apps/${appId}`)
|
api.get<any, ApiResponse<AppDetail>>(`/apps/${appId}`),
|
||||||
|
|
||||||
|
getAppsByDate: (date: string) =>
|
||||||
|
api.get<any, ApiResponse<AppItem[]>>('/apps/by-date', { params: { date } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
233
frontend/src/components/Footer.vue
Normal file
233
frontend/src/components/Footer.vue
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="footer-container">
|
||||||
|
<div class="footer-grid">
|
||||||
|
<div class="footer-column">
|
||||||
|
<h4 class="footer-column-title">关于 NEXT Store</h4>
|
||||||
|
<p class="footer-description">NEXT Store 是一个展示和分享鸿蒙应用的平台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-column">
|
||||||
|
<h4 class="footer-column-title">快速链接</h4>
|
||||||
|
<div class="footer-links-group">
|
||||||
|
<router-link to="/" class="footer-link">探索</router-link>
|
||||||
|
<router-link to="/apps" class="footer-link">应用</router-link>
|
||||||
|
<router-link to="/new_apps" class="footer-link">上新</router-link>
|
||||||
|
<router-link to="/hot_apps" class="footer-link">热门</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-column">
|
||||||
|
<h4 class="footer-column-title">法律信息</h4>
|
||||||
|
<div class="license-info">
|
||||||
|
<div class="license-icons">
|
||||||
|
<i class="fab fa-creative-commons"></i>
|
||||||
|
<i class="fab fa-creative-commons-by"></i>
|
||||||
|
<i class="fab fa-creative-commons-nc"></i>
|
||||||
|
<i class="fab fa-creative-commons-sa"></i>
|
||||||
|
</div>
|
||||||
|
<div class="license-text">
|
||||||
|
<span>本站内容采用 CC BY-NC-SA 4.0 许可协议</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<div class="footer-bottom-content">
|
||||||
|
<div class="footer-links">
|
||||||
|
<div class="footer-copyright">© 2024-2025 NEXT Store. 保留所有权利</div>
|
||||||
|
<span class="divider">|</span>
|
||||||
|
<span class="footer-text">本网站仅用于演示目的,所有展示的应用仅供参考</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 40px 0 20px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 40px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-column-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links-group .footer-link {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links-group .footer-link:hover {
|
||||||
|
color: #007AFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-icons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-text a {
|
||||||
|
color: #007AFF;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-text a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-copyright {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.footer {
|
||||||
|
padding: 30px 0 15px;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-container {
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 25px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-column-title {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-description,
|
||||||
|
.footer-links-group .footer-link,
|
||||||
|
.license-text {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-copyright,
|
||||||
|
.footer-text {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-icons {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.footer {
|
||||||
|
padding: 20px 0 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-grid {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,8 @@ import Home from '@/views/Home.vue'
|
|||||||
import Apps from '@/views/Apps.vue'
|
import Apps from '@/views/Apps.vue'
|
||||||
import AppDetail from '@/views/AppDetail.vue'
|
import AppDetail from '@/views/AppDetail.vue'
|
||||||
import Profile from '@/views/Profile.vue'
|
import Profile from '@/views/Profile.vue'
|
||||||
|
import NewApps from '@/views/NewApps.vue'
|
||||||
|
import HotApps from '@/views/HotApps.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -26,6 +28,16 @@ const router = createRouter({
|
|||||||
path: '/profile',
|
path: '/profile',
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
component: Profile
|
component: Profile
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/new_apps',
|
||||||
|
name: 'NewApps',
|
||||||
|
component: NewApps
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/hot_apps',
|
||||||
|
name: 'HotApps',
|
||||||
|
component: HotApps
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,88 +1,165 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-detail" v-if="app">
|
<div class="app-detail-container" v-if="app">
|
||||||
<div class="container">
|
<!-- 返回按钮和头部 -->
|
||||||
<div class="detail-header">
|
<div class="app-header">
|
||||||
|
<div class="back-button-wrapper">
|
||||||
|
<button @click="goBack" class="back-link">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-basic-info">
|
||||||
<div class="app-icon">
|
<div class="app-icon">
|
||||||
<img :src="app.icon_url" :alt="app.name" />
|
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="app-title">
|
||||||
|
<div class="title-row">
|
||||||
|
<div class="title-info">
|
||||||
|
<h1>{{ app.name }}</h1>
|
||||||
|
<span class="category-tag">{{ app.kind_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a
|
||||||
|
v-if="getAppStoreUrl()"
|
||||||
|
:href="getAppStoreUrl()"
|
||||||
|
class="download-button"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
下载
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-header-info">
|
|
||||||
<h1 class="app-title">{{ app.name }}</h1>
|
|
||||||
<p class="app-developer">{{ app.developer_name }}</p>
|
<p class="app-developer">{{ app.developer_name }}</p>
|
||||||
<div class="app-stats">
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">评分</span>
|
|
||||||
<span class="stat-value">{{ app.average_rating.toFixed(1) }} ⭐</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">下载</span>
|
|
||||||
<span class="stat-value">{{ formatDownloads(app.download_count) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">大小</span>
|
|
||||||
<span class="stat-value">{{ formatSize(app.size_bytes) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-content">
|
<!-- 应用统计信息卡片 -->
|
||||||
<section class="section">
|
<div class="app-stats-card">
|
||||||
<h2 class="section-title">应用简介</h2>
|
<div class="stat-item">
|
||||||
<p class="app-description">{{ app.description }}</p>
|
<div class="stat-value">{{ app.average_rating.toFixed(1) }}</div>
|
||||||
</section>
|
<div class="stat-label">
|
||||||
|
<span class="stars">{{ '⭐'.repeat(Math.round(app.average_rating)) }}</span>
|
||||||
|
<span class="rating-count">{{ formatNumber(app.total_rating_count || 0) }}个评分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-divider"></div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ formatDownloads(app.download_count) }}</div>
|
||||||
|
<div class="stat-label">下载量</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-divider"></div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ formatSize(app.size_bytes) }}</div>
|
||||||
|
<div class="stat-label">大小</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="section" v-if="app.total_rating_count">
|
<!-- 应用内容 -->
|
||||||
<h2 class="section-title">评分分布</h2>
|
<div class="app-content">
|
||||||
<div class="rating-bars">
|
<!-- 应用描述 -->
|
||||||
<div v-for="i in 5" :key="i" class="rating-bar">
|
<div class="app-section">
|
||||||
|
<h2>应用描述</h2>
|
||||||
|
<div class="description">{{ app.description || app.brief_desc || '暂无描述' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 评分分布 -->
|
||||||
|
<div class="app-section" v-if="app.total_rating_count">
|
||||||
|
<h2>评分分布</h2>
|
||||||
|
<div class="rating-distribution">
|
||||||
|
<div v-for="i in 5" :key="i" class="rating-row">
|
||||||
<span class="star-label">{{ 6 - i }}星</span>
|
<span class="star-label">{{ 6 - i }}星</span>
|
||||||
<div class="bar">
|
<div class="rating-bar-container">
|
||||||
<div class="bar-fill" :style="{ width: getRatingPercent(6 - i) + '%' }"></div>
|
<div class="rating-bar" :style="{ width: getRatingPercent(6 - i) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="rating-count">{{ getRatingCount(6 - i) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="count">{{ getRatingCount(6 - i) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
<!-- 应用信息 -->
|
||||||
<h2 class="section-title">应用信息</h2>
|
<div class="app-section">
|
||||||
|
<h2>应用信息</h2>
|
||||||
<div class="info-list">
|
<div class="info-list">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">版本</span>
|
<label>版本</label>
|
||||||
<span class="info-value">{{ app.version }}</span>
|
<span>{{ app.version || '暂无' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">分类</span>
|
<label>分类</label>
|
||||||
<span class="info-value">{{ app.kind_name }}</span>
|
<span>{{ app.kind_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">上架时间</span>
|
<label>上架时间</label>
|
||||||
<span class="info-value">{{ formatDate(app.listed_at) }}</span>
|
<span>{{ formatDate(app.listed_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">包名</span>
|
<label>开发者</label>
|
||||||
<span class="info-value">{{ app.pkg_name }}</span>
|
<span>{{ app.developer_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>包名</label>
|
||||||
|
<span class="package-name">{{ app.pkg_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="info-item">
|
||||||
|
<label>价格</label>
|
||||||
|
<span :class="!app.is_pay ? 'free-tag' : ''">{{ app.is_pay ? app.price : '免费' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>支持平台</label>
|
||||||
|
<span class="platform-support">
|
||||||
|
<span
|
||||||
|
v-for="(device, index) in getDeviceList()"
|
||||||
|
:key="index"
|
||||||
|
:class="['platform-tag', device.type]"
|
||||||
|
>
|
||||||
|
<i :class="device.icon"></i>
|
||||||
|
{{ device.name }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="loading">加载中...</div>
|
|
||||||
|
<!-- 隐私政策 -->
|
||||||
|
<div class="app-section" v-if="app.privacy_url">
|
||||||
|
<h2>隐私政策</h2>
|
||||||
|
<a :href="app.privacy_url" target="_blank" class="privacy-link">
|
||||||
|
<i class="fas fa-external-link-alt"></i>
|
||||||
|
查看隐私政策
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="loading-container">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { appsApi, type AppDetail } from '@/api'
|
import { appsApi, type AppDetail } from '@/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const app = ref<AppDetail | null>(null)
|
const app = ref<AppDetail | null>(null)
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await appsApi.getDetail(route.params.id as string)
|
const res = await appsApi.getDetail(route.params.id as string)
|
||||||
if (res.success) app.value = res.data
|
if (res.success) {
|
||||||
|
app.value = res.data
|
||||||
|
console.log('应用详情:', app.value)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载应用详情失败:', error)
|
console.error('加载应用详情失败:', error)
|
||||||
}
|
}
|
||||||
@@ -94,6 +171,11 @@ const formatDownloads = (count: number): string => {
|
|||||||
return count.toString()
|
return count.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatNumber = (num: number): string => {
|
||||||
|
if (num >= 10000) return `${(num / 10000).toFixed(1)}万`
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
||||||
const formatSize = (bytes: number): string => {
|
const formatSize = (bytes: number): string => {
|
||||||
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`
|
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`
|
||||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)}MB`
|
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)}MB`
|
||||||
@@ -101,203 +183,520 @@ const formatSize = (bytes: number): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date: string): string => {
|
const formatDate = (date: string): string => {
|
||||||
return new Date(date).toLocaleDateString('zh-CN')
|
if (!date) return '暂无'
|
||||||
|
return new Date(date).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRatingCount = (star: number): number => {
|
const getRatingCount = (star: number): number => {
|
||||||
if (!app.value) return 0
|
if (!app.value) return 0
|
||||||
return app.value[`star_${star}_count` as keyof AppDetail] as number
|
return app.value[`star_${star}_count` as keyof AppDetail] as number || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRatingPercent = (star: number): number => {
|
const getRatingPercent = (star: number): number => {
|
||||||
if (!app.value || !app.value.total_rating_count) return 0
|
if (!app.value || !app.value.total_rating_count) return 0
|
||||||
const count = getRatingCount(star)
|
const count = getRatingCount(star)
|
||||||
return (count / app.value.total_rating_count) * 100
|
return (count / (app.value.total_rating_count || 1)) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDeviceList = () => {
|
||||||
|
if (!app.value?.main_device_codes || app.value.main_device_codes.length === 0) {
|
||||||
|
return [{ name: '手机端', icon: 'fas fa-mobile-alt', type: 'mobile' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceMap: Record<string, { name: string; icon: string; type: string }> = {
|
||||||
|
'0': { name: '手机端', icon: 'fas fa-mobile-alt', type: 'mobile' },
|
||||||
|
'1': { name: '平板端', icon: 'fas fa-tablet-alt', type: 'tablet' },
|
||||||
|
'2': { name: '智慧屏', icon: 'fas fa-tv', type: 'tv' },
|
||||||
|
'3': { name: '手表', icon: 'fas fa-clock', type: 'watch' },
|
||||||
|
'4': { name: '车机', icon: 'fas fa-car', type: 'car' },
|
||||||
|
'5': { name: 'PC', icon: 'fas fa-desktop', type: 'pc' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.value.main_device_codes
|
||||||
|
.map(code => deviceMap[code] || { name: `设备${code}`, icon: 'fas fa-question', type: 'unknown' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAppStoreUrl = (): string => {
|
||||||
|
if (!app.value) return ''
|
||||||
|
// 构建华为应用市场的URL
|
||||||
|
return `https://appgallery.huawei.com/app/${app.value.app_id}`
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-detail {
|
.app-detail-container {
|
||||||
padding: 40px 0;
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 15px;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
background: #F5F5F7;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-header {
|
.app-header {
|
||||||
display: flex;
|
|
||||||
gap: 24px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
padding: 32px;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: var(--border-radius);
|
padding: 15px;
|
||||||
box-shadow: var(--card-shadow);
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button-wrapper {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f5f5f7;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link i {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
background: #e8e8e8;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-basic-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-icon {
|
.app-icon {
|
||||||
width: 120px;
|
width: 80px;
|
||||||
height: 120px;
|
height: 80px;
|
||||||
border-radius: var(--border-radius);
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-icon img {
|
.app-icon img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header-info {
|
.app-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-title {
|
.title-row {
|
||||||
font-size: 32px;
|
display: flex;
|
||||||
font-weight: 600;
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
gap: 15px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-developer {
|
.title-info {
|
||||||
font-size: 16px;
|
|
||||||
color: #86868b;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-info h1 {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tag {
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #007AFF;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-developer {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计信息卡片 */
|
||||||
|
.app-stats-card {
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #86868b;
|
color: #666;
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-content {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 32px;
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.stars {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 40px;
|
||||||
|
background: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用内容 */
|
||||||
|
.app-content {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 32px;
|
padding: 15px;
|
||||||
border-radius: var(--border-radius);
|
border-radius: 12px;
|
||||||
box-shadow: var(--card-shadow);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.app-section {
|
||||||
font-size: 20px;
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-section h2 {
|
||||||
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 16px;
|
margin: 0 0 12px 0;
|
||||||
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-description {
|
.description {
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #1d1d1f;
|
color: #444;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rating-bars {
|
/* 评分分布 */
|
||||||
|
.rating-distribution {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rating-bar {
|
.rating-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.star-label {
|
.star-label {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #86868b;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar {
|
.rating-bar-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: #f5f5f7;
|
background: #f0f0f0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-fill {
|
.rating-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #f5a623;
|
background: linear-gradient(90deg, #FFD700, #FFA500);
|
||||||
transition: width 0.3s;
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
.rating-count {
|
||||||
width: 60px;
|
width: 80px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #86868b;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 信息列表 */
|
||||||
.info-list {
|
.info-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item {
|
.info-item {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-bottom: 16px;
|
align-items: center;
|
||||||
border-bottom: 1px solid #f5f5f7;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item:last-child {
|
.info-item:last-child {
|
||||||
border-bottom: none;
|
margin-bottom: 0;
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-label {
|
.info-item label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item span {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #86868b;
|
color: #1a1a1a;
|
||||||
|
text-align: right;
|
||||||
|
max-width: 60%;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.package-name {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-tag {
|
||||||
|
color: #34C759;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-support {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f5f5f7;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag i {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag.mobile {
|
||||||
|
color: #007AFF;
|
||||||
|
background: rgba(0, 122, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag.tablet {
|
||||||
|
color: #5856D6;
|
||||||
|
background: rgba(88, 86, 214, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag.tv {
|
||||||
|
color: #FF9500;
|
||||||
|
background: rgba(255, 149, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag.watch {
|
||||||
|
color: #FF2D55;
|
||||||
|
background: rgba(255, 45, 85, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag.car {
|
||||||
|
color: #34C759;
|
||||||
|
background: rgba(52, 199, 89, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag.pc {
|
||||||
|
color: #5AC8FA;
|
||||||
|
background: rgba(90, 200, 250, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐私政策链接 */
|
||||||
|
.privacy-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #007AFF;
|
||||||
|
text-decoration: none;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #1d1d1f;
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 122, 255, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.privacy-link:hover {
|
||||||
.detail-header {
|
background: rgba(0, 122, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-link i {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态 */
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 20px;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid #f0f0f0;
|
||||||
|
border-top-color: #007AFF;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-detail-container {
|
||||||
|
padding: 10px;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-icon {
|
.app-icon {
|
||||||
width: 100px;
|
width: 70px;
|
||||||
height: 100px;
|
height: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-title {
|
.title-info h1 {
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.title-row {
|
||||||
padding: 20px;
|
flex-direction: column;
|
||||||
}
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.action-buttons {
|
||||||
text-align: center;
|
width: 100%;
|
||||||
padding: 60px 20px;
|
justify-content: flex-start;
|
||||||
color: #86868b;
|
}
|
||||||
font-size: 16px;
|
|
||||||
|
.app-stats-card {
|
||||||
|
padding: 15px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item span {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-support {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,39 +1,118 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="apps-page">
|
<div class="apps-page">
|
||||||
<div class="container">
|
<!-- 网站标题栏 -->
|
||||||
|
<div class="site-header">
|
||||||
|
<div class="site-title">
|
||||||
|
<h1>应用</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
|
<div class="search-container">
|
||||||
|
<form class="search-form" @submit.prevent="handleSearch">
|
||||||
|
<div class="search-wrapper" :class="{ 'search-focused': isSearchFocused }">
|
||||||
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<path d="m21 21-4.35-4.35"/>
|
||||||
|
</svg>
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
@keyup.enter="handleSearch"
|
type="search"
|
||||||
type="text"
|
placeholder="应用、游戏等"
|
||||||
placeholder="搜索应用..."
|
@focus="isSearchFocused = true"
|
||||||
class="search-input"
|
@blur="isSearchFocused = false"
|
||||||
|
@input="handleSearchInput"
|
||||||
/>
|
/>
|
||||||
<button @click="handleSearch" class="search-btn">搜索</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="categories">
|
|
||||||
<button
|
<button
|
||||||
v-for="category in categories"
|
v-if="searchQuery"
|
||||||
:key="category.name"
|
type="button"
|
||||||
@click="selectCategory(category.name)"
|
class="clear-button"
|
||||||
:class="['category-tag', { active: selectedCategory === category.name }]"
|
@click="clearSearch"
|
||||||
>
|
>
|
||||||
{{ category.name }} ({{ category.count }})
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<circle cx="10" cy="10" r="10" opacity="0.3"/>
|
||||||
|
<path d="M6.5 6.5l7 7M13.5 6.5l-7 7" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
<div class="apps-list">
|
v-if="searchQuery || selectedCategory"
|
||||||
<div class="app-grid" v-if="apps.length">
|
type="button"
|
||||||
<AppCard v-for="app in apps" :key="app.app_id" :app="app" />
|
class="cancel-button"
|
||||||
|
@click="cancelSearch"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="loading" class="loading">加载中...</div>
|
|
||||||
<div v-else class="empty">暂无应用</div>
|
|
||||||
|
|
||||||
<div v-if="total > pageSize" class="pagination">
|
<div class="app-showcase">
|
||||||
<button @click="prevPage" :disabled="page === 1" class="page-btn">上一页</button>
|
<!-- 分类磁贴区域 -->
|
||||||
|
<div v-if="!searchQuery && !selectedCategory" class="categories-grid">
|
||||||
|
<div
|
||||||
|
v-for="(category, index) in categories"
|
||||||
|
:key="category.name"
|
||||||
|
:class="['category-tile', `category-color-${index % 16}`]"
|
||||||
|
@click="selectCategory(category.name)"
|
||||||
|
>
|
||||||
|
<i :class="'category-icon fas ' + getCategoryIcon(category.name)"></i>
|
||||||
|
<div class="category-tile-header">
|
||||||
|
<h3>{{ category.name }}</h3>
|
||||||
|
<span class="app-count">{{ category.count }}个应用</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索/分类结果区域 -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="apps-grid">
|
||||||
|
<div v-for="i in 12" :key="`skeleton-${i}`" class="app-card skeleton">
|
||||||
|
<div class="app-icon skeleton-box"></div>
|
||||||
|
<div class="app-info">
|
||||||
|
<div class="skeleton-text"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 应用列表 -->
|
||||||
|
<div v-else-if="apps.length" class="apps-grid">
|
||||||
|
<div
|
||||||
|
v-for="app in apps"
|
||||||
|
:key="app.app_id"
|
||||||
|
class="app-card"
|
||||||
|
@click="goToApp(app.app_id)"
|
||||||
|
>
|
||||||
|
<div class="app-icon">
|
||||||
|
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="app-info">
|
||||||
|
<h3>{{ app.name }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无结果状态 -->
|
||||||
|
<div v-else class="no-results">
|
||||||
|
<div class="no-results-icon">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</div>
|
||||||
|
<div class="no-results-text">未找到相关应用</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div v-if="total > pageSize && apps.length" class="pagination">
|
||||||
|
<button @click="prevPage" :disabled="page === 1" class="page-btn">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
<span class="page-info">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
|
<span class="page-info">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
|
||||||
<button @click="nextPage" :disabled="page >= Math.ceil(total / pageSize)" class="page-btn">下一页</button>
|
<button @click="nextPage" :disabled="page >= Math.ceil(total / pageSize)" class="page-btn">
|
||||||
|
下一页
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,9 +121,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { appsApi, type AppItem, type Category } from '@/api'
|
import { appsApi, type AppItem, type Category } from '@/api'
|
||||||
import AppCard from '@/components/AppCard.vue'
|
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
const categories = ref<Category[]>([])
|
const categories = ref<Category[]>([])
|
||||||
@@ -53,16 +133,24 @@ const page = ref(1)
|
|||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const isSearchFocused = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await appsApi.getCategories()
|
const res = await appsApi.getCategories()
|
||||||
if (res.success) categories.value = res.data
|
if (res.success) {
|
||||||
|
categories.value = res.data
|
||||||
|
console.log('分类数据:', categories.value)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载分类失败:', error)
|
console.error('加载分类失败:', error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const goToApp = (appId: string) => {
|
||||||
|
router.push(`/app/${appId}`)
|
||||||
|
}
|
||||||
|
|
||||||
const selectCategory = async (category: string) => {
|
const selectCategory = async (category: string) => {
|
||||||
selectedCategory.value = category
|
selectedCategory.value = category
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
@@ -71,12 +159,36 @@ const selectCategory = async (category: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
if (!searchQuery.value.trim()) return
|
if (!searchQuery.value.trim()) {
|
||||||
|
// 如果搜索框为空,返回分类视图
|
||||||
|
selectedCategory.value = ''
|
||||||
|
apps.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
page.value = 1
|
page.value = 1
|
||||||
await loadApps()
|
await loadApps()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSearchInput = () => {
|
||||||
|
// 实时搜索(可选)
|
||||||
|
if (searchQuery.value.trim()) {
|
||||||
|
// 可以添加防抖逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
searchQuery.value = ''
|
||||||
|
selectedCategory.value = ''
|
||||||
|
apps.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelSearch = () => {
|
||||||
|
searchQuery.value = ''
|
||||||
|
selectedCategory.value = ''
|
||||||
|
apps.value = []
|
||||||
|
}
|
||||||
|
|
||||||
const loadApps = async () => {
|
const loadApps = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -102,6 +214,7 @@ const prevPage = () => {
|
|||||||
if (page.value > 1) {
|
if (page.value > 1) {
|
||||||
page.value--
|
page.value--
|
||||||
loadApps()
|
loadApps()
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,114 +222,569 @@ const nextPage = () => {
|
|||||||
if (page.value < Math.ceil(total.value / pageSize.value)) {
|
if (page.value < Math.ceil(total.value / pageSize.value)) {
|
||||||
page.value++
|
page.value++
|
||||||
loadApps()
|
loadApps()
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据分类名称返回对应的图标
|
||||||
|
const getCategoryIcon = (categoryName: string): string => {
|
||||||
|
console.log('获取图标:', categoryName)
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
// 常见分类
|
||||||
|
'游戏': 'fa-gamepad',
|
||||||
|
'社交': 'fa-comments',
|
||||||
|
'通讯': 'fa-comment-dots',
|
||||||
|
'娱乐': 'fa-film',
|
||||||
|
'影音': 'fa-play-circle',
|
||||||
|
'视频': 'fa-video',
|
||||||
|
'音乐': 'fa-music',
|
||||||
|
'摄影': 'fa-camera',
|
||||||
|
'图像': 'fa-image',
|
||||||
|
'工具': 'fa-wrench',
|
||||||
|
'效率': 'fa-briefcase',
|
||||||
|
'办公': 'fa-file-alt',
|
||||||
|
'生活': 'fa-home',
|
||||||
|
'购物': 'fa-shopping-cart',
|
||||||
|
'美食': 'fa-utensils',
|
||||||
|
'旅游': 'fa-plane',
|
||||||
|
'出行': 'fa-car',
|
||||||
|
'导航': 'fa-map-marked-alt',
|
||||||
|
'新闻': 'fa-newspaper',
|
||||||
|
'阅读': 'fa-book',
|
||||||
|
'教育': 'fa-graduation-cap',
|
||||||
|
'学习': 'fa-book-reader',
|
||||||
|
'儿童': 'fa-child',
|
||||||
|
'健康': 'fa-heartbeat',
|
||||||
|
'运动': 'fa-running',
|
||||||
|
'财务': 'fa-wallet',
|
||||||
|
'金融': 'fa-chart-line',
|
||||||
|
'理财': 'fa-coins',
|
||||||
|
'商务': 'fa-building',
|
||||||
|
'医疗': 'fa-hospital',
|
||||||
|
'天气': 'fa-cloud-sun',
|
||||||
|
'美化': 'fa-palette',
|
||||||
|
'主题': 'fa-paint-brush',
|
||||||
|
'壁纸': 'fa-image',
|
||||||
|
'输入法': 'fa-keyboard',
|
||||||
|
'浏览器': 'fa-globe',
|
||||||
|
'安全': 'fa-shield-alt',
|
||||||
|
'系统': 'fa-cog',
|
||||||
|
'设置': 'fa-sliders-h',
|
||||||
|
'开发': 'fa-code',
|
||||||
|
'编程': 'fa-laptop-code',
|
||||||
|
'设计': 'fa-pencil-ruler',
|
||||||
|
'参考': 'fa-book-open',
|
||||||
|
'杂志': 'fa-newspaper',
|
||||||
|
'漫画': 'fa-book-open',
|
||||||
|
'小说': 'fa-book',
|
||||||
|
'体育': 'fa-football-ball',
|
||||||
|
'直播': 'fa-broadcast-tower',
|
||||||
|
'短视频': 'fa-video',
|
||||||
|
'电台': 'fa-podcast',
|
||||||
|
'播客': 'fa-microphone',
|
||||||
|
'笔记': 'fa-sticky-note',
|
||||||
|
'日历': 'fa-calendar-alt',
|
||||||
|
'时钟': 'fa-clock',
|
||||||
|
'计算器': 'fa-calculator',
|
||||||
|
'翻译': 'fa-language',
|
||||||
|
'词典': 'fa-book',
|
||||||
|
'地图': 'fa-map',
|
||||||
|
'天气': 'fa-cloud-sun-rain',
|
||||||
|
'邮件': 'fa-envelope',
|
||||||
|
'云盘': 'fa-cloud',
|
||||||
|
'文件': 'fa-folder',
|
||||||
|
'压缩': 'fa-file-archive',
|
||||||
|
'清理': 'fa-broom',
|
||||||
|
'优化': 'fa-tachometer-alt',
|
||||||
|
'省电': 'fa-battery-three-quarters',
|
||||||
|
'网络': 'fa-wifi',
|
||||||
|
'蓝牙': 'fa-bluetooth',
|
||||||
|
'投屏': 'fa-tv',
|
||||||
|
'遥控': 'fa-mobile-alt',
|
||||||
|
'智能家居': 'fa-home',
|
||||||
|
'物联网': 'fa-network-wired',
|
||||||
|
'汽车': 'fa-car',
|
||||||
|
'驾驶': 'fa-car-side',
|
||||||
|
'停车': 'fa-parking',
|
||||||
|
'加油': 'fa-gas-pump',
|
||||||
|
'违章': 'fa-exclamation-triangle',
|
||||||
|
'保险': 'fa-shield-alt',
|
||||||
|
'银行': 'fa-university',
|
||||||
|
'支付': 'fa-credit-card',
|
||||||
|
'股票': 'fa-chart-line',
|
||||||
|
'基金': 'fa-chart-pie',
|
||||||
|
'彩票': 'fa-ticket-alt',
|
||||||
|
'外卖': 'fa-hamburger',
|
||||||
|
'团购': 'fa-users',
|
||||||
|
'酒店': 'fa-hotel',
|
||||||
|
'机票': 'fa-plane-departure',
|
||||||
|
'火车': 'fa-train',
|
||||||
|
'租车': 'fa-car',
|
||||||
|
'打车': 'fa-taxi',
|
||||||
|
'共享': 'fa-bicycle',
|
||||||
|
'快递': 'fa-shipping-fast',
|
||||||
|
'物流': 'fa-truck',
|
||||||
|
'二手': 'fa-recycle',
|
||||||
|
'招聘': 'fa-user-tie',
|
||||||
|
'房产': 'fa-home',
|
||||||
|
'装修': 'fa-hammer',
|
||||||
|
'家政': 'fa-broom',
|
||||||
|
'维修': 'fa-tools',
|
||||||
|
'宠物': 'fa-paw',
|
||||||
|
'母婴': 'fa-baby',
|
||||||
|
'亲子': 'fa-baby-carriage',
|
||||||
|
'婚恋': 'fa-heart',
|
||||||
|
'交友': 'fa-user-friends',
|
||||||
|
'社区': 'fa-users',
|
||||||
|
'论坛': 'fa-comments',
|
||||||
|
'博客': 'fa-blog',
|
||||||
|
'微博': 'fa-comment',
|
||||||
|
'问答': 'fa-question-circle',
|
||||||
|
'百科': 'fa-book',
|
||||||
|
'搜索': 'fa-search',
|
||||||
|
'浏览': 'fa-eye',
|
||||||
|
'下载': 'fa-download',
|
||||||
|
'上传': 'fa-upload',
|
||||||
|
'分享': 'fa-share-alt',
|
||||||
|
'收藏': 'fa-star',
|
||||||
|
'标签': 'fa-tags',
|
||||||
|
'分类': 'fa-th-list',
|
||||||
|
'排行': 'fa-trophy',
|
||||||
|
'推荐': 'fa-thumbs-up',
|
||||||
|
'热门': 'fa-fire',
|
||||||
|
'最新': 'fa-clock',
|
||||||
|
'精选': 'fa-gem',
|
||||||
|
'专题': 'fa-folder-open',
|
||||||
|
'合集': 'fa-layer-group',
|
||||||
|
'系列': 'fa-list-ol',
|
||||||
|
'套装': 'fa-box',
|
||||||
|
'限免': 'fa-gift',
|
||||||
|
'折扣': 'fa-percentage',
|
||||||
|
'会员': 'fa-crown',
|
||||||
|
'VIP': 'fa-crown',
|
||||||
|
'付费': 'fa-dollar-sign',
|
||||||
|
'免费': 'fa-gift',
|
||||||
|
'试用': 'fa-vial',
|
||||||
|
'测试': 'fa-flask',
|
||||||
|
'预览': 'fa-eye',
|
||||||
|
'演示': 'fa-desktop',
|
||||||
|
'教程': 'fa-chalkboard-teacher',
|
||||||
|
'帮助': 'fa-question-circle',
|
||||||
|
'反馈': 'fa-comment-dots',
|
||||||
|
'设置': 'fa-cog',
|
||||||
|
'关于': 'fa-info-circle',
|
||||||
|
'更多': 'fa-ellipsis-h'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试精确匹配
|
||||||
|
if (iconMap[categoryName]) {
|
||||||
|
console.log('精确匹配:', categoryName, '->', iconMap[categoryName])
|
||||||
|
return iconMap[categoryName]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试模糊匹配
|
||||||
|
for (const [key, icon] of Object.entries(iconMap)) {
|
||||||
|
if (categoryName.includes(key) || key.includes(categoryName)) {
|
||||||
|
console.log('模糊匹配:', categoryName, '->', icon)
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认图标
|
||||||
|
console.log('使用默认图标:', categoryName)
|
||||||
|
return 'fa-th-large'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.apps-page {
|
.apps-page {
|
||||||
padding: 40px 0;
|
background: #F5F5F7;
|
||||||
background: #fff;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
padding-top: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 网站标题栏 */
|
||||||
|
.site-header {
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
padding: 8px 15px 3px 15px;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-title h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 搜索栏 */
|
||||||
.search-bar {
|
.search-bar {
|
||||||
display: flex;
|
background: #F5F5F7;
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px solid #d2d2d7;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
font-size: 15px;
|
|
||||||
outline: none;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
border-color: #0071e3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-btn {
|
|
||||||
padding: 12px 24px;
|
|
||||||
background: #0071e3;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
font-size: 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-btn:hover {
|
|
||||||
background: #0077ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.categories {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-tag {
|
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: #fff;
|
position: sticky;
|
||||||
border: 1px solid #d2d2d7;
|
top: 60px;
|
||||||
border-radius: var(--border-radius);
|
z-index: 998;
|
||||||
font-size: 14px;
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: white;
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 0 12px;
|
||||||
|
height: 44px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper.search-focused {
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #999;
|
||||||
|
flex-shrink: 0;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper input::placeholder {
|
||||||
|
color: #999;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-tag:hover {
|
.clear-button:hover {
|
||||||
border-color: #0071e3;
|
opacity: 0.7;
|
||||||
color: #0071e3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-tag.active {
|
.clear-button svg {
|
||||||
background: #0071e3;
|
width: 20px;
|
||||||
color: #fff;
|
height: 20px;
|
||||||
border-color: #0071e3;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-grid {
|
.cancel-button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #007AFF;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:active {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用展示区域 */
|
||||||
|
.app-showcase {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 15px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分类磁贴网格 */
|
||||||
|
.categories-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.app-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 15px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile:hover .category-icon {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-icon {
|
||||||
|
position: absolute;
|
||||||
|
right: -10px;
|
||||||
|
bottom: -10px;
|
||||||
|
font-size: 48px;
|
||||||
|
color: white;
|
||||||
|
opacity: 0.1;
|
||||||
|
transform: rotate(-15deg);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile-header h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分类颜色 */
|
||||||
|
.category-color-0::before { background: #FF6B6B; }
|
||||||
|
.category-color-1::before { background: #4ECDC4; }
|
||||||
|
.category-color-2::before { background: #45B7D1; }
|
||||||
|
.category-color-3::before { background: #96CEB4; }
|
||||||
|
.category-color-4::before { background: #FFB75E; }
|
||||||
|
.category-color-5::before { background: #D4A5A5; }
|
||||||
|
.category-color-6::before { background: #9B59B6; }
|
||||||
|
.category-color-7::before { background: #3498DB; }
|
||||||
|
.category-color-8::before { background: #2ECC71; }
|
||||||
|
.category-color-9::before { background: #F1C40F; }
|
||||||
|
.category-color-10::before { background: #E74C3C; }
|
||||||
|
.category-color-11::before { background: #1ABC9C; }
|
||||||
|
.category-color-12::before { background: #8E44AD; }
|
||||||
|
.category-color-13::before { background: #D35400; }
|
||||||
|
.category-color-14::before { background: #16A085; }
|
||||||
|
.category-color-15::before { background: #E67E22; }
|
||||||
|
|
||||||
|
/* 应用网格 */
|
||||||
|
.apps-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 无结果状态 */
|
||||||
|
.no-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results-text {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架屏 */
|
||||||
|
.skeleton {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-box {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text {
|
||||||
|
height: 10px;
|
||||||
|
width: 90%;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 分页 */
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-top: 40px;
|
margin: 30px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-btn {
|
.page-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: #fff;
|
background: white;
|
||||||
border: 1px solid #d2d2d7;
|
border: 1px solid #e0e0e0;
|
||||||
border-radius: var(--border-radius);
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: all 0.2s;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-btn:hover:not(:disabled) {
|
.page-btn:hover:not(:disabled) {
|
||||||
border-color: #0071e3;
|
border-color: #007AFF;
|
||||||
color: #0071e3;
|
color: #007AFF;
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-btn:disabled {
|
.page-btn:disabled {
|
||||||
@@ -225,14 +793,78 @@ const nextPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-info {
|
.page-info {
|
||||||
color: #86868b;
|
color: #666;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading, .empty {
|
/* 响应式布局 */
|
||||||
text-align: center;
|
@media (max-width: 768px) {
|
||||||
padding: 60px 20px;
|
.categories-grid {
|
||||||
color: #86868b;
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
font-size: 16px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile {
|
||||||
|
padding: 15px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-icon {
|
||||||
|
font-size: 42px;
|
||||||
|
right: -8px;
|
||||||
|
bottom: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile-header h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-count {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info h3 {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.site-title h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper input {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,6 +14,17 @@
|
|||||||
<img src="/new.png" alt="今日上架" class="explore-bg" />
|
<img src="/new.png" alt="今日上架" class="explore-bg" />
|
||||||
<div class="new-apps-bar">
|
<div class="new-apps-bar">
|
||||||
<div class="apps-row">
|
<div class="apps-row">
|
||||||
|
<!-- 加载中的骨架屏 -->
|
||||||
|
<template v-if="isLoading">
|
||||||
|
<div v-for="i in 10" :key="`skeleton-${i}`" class="app-card skeleton">
|
||||||
|
<div class="app-icon skeleton-box"></div>
|
||||||
|
<div class="app-info">
|
||||||
|
<div class="skeleton-text"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 实际内容 -->
|
||||||
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-for="app in todayApps.slice(0, 10)"
|
v-for="app in todayApps.slice(0, 10)"
|
||||||
:key="app.app_id"
|
:key="app.app_id"
|
||||||
@@ -28,8 +39,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!todayApps.length" class="no-apps-message">
|
<div v-if="!todayApps.length" class="no-apps-message">
|
||||||
今日暂无新应用
|
今天暂无新上架应用
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -50,11 +62,23 @@
|
|||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>热门应用</h2>
|
<h2>热门应用</h2>
|
||||||
</div>
|
</div>
|
||||||
<router-link to="/apps" class="view-all">
|
<router-link to="/hot_apps" class="view-all">
|
||||||
查看全部 <i class="fas fa-chevron-right"></i>
|
查看全部 <i class="fas fa-chevron-right"></i>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="apps-list">
|
<div class="apps-list">
|
||||||
|
<!-- 加载中的骨架屏 -->
|
||||||
|
<template v-if="isLoading">
|
||||||
|
<div v-for="i in 5" :key="`skeleton-${i}`" class="app-item skeleton">
|
||||||
|
<div class="app-icon skeleton-box"></div>
|
||||||
|
<div class="app-info">
|
||||||
|
<div class="skeleton-text skeleton-title"></div>
|
||||||
|
<div class="skeleton-text skeleton-subtitle"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 实际内容 -->
|
||||||
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-for="app in topDownloads.slice(0, 5)"
|
v-for="app in topDownloads.slice(0, 5)"
|
||||||
:key="app.app_id"
|
:key="app.app_id"
|
||||||
@@ -72,6 +96,7 @@
|
|||||||
<div v-if="!topDownloads.length" class="no-apps-message">
|
<div v-if="!topDownloads.length" class="no-apps-message">
|
||||||
暂无热门应用
|
暂无热门应用
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
@@ -87,37 +112,47 @@ import { appsApi, type AppItem } from '@/api'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const todayApps = ref<AppItem[]>([])
|
const todayApps = ref<AppItem[]>([])
|
||||||
const topDownloads = ref<AppItem[]>([])
|
const topDownloads = ref<AppItem[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
const goToApp = (appId: string) => {
|
const goToApp = (appId: string) => {
|
||||||
router.push(`/app/${appId}`)
|
router.push(`/app/${appId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToNewApps = () => {
|
const goToNewApps = () => {
|
||||||
router.push('/apps')
|
router.push('/new_apps')
|
||||||
}
|
}
|
||||||
|
|
||||||
const openHarmonyOS = () => {
|
const openHarmonyOS = () => {
|
||||||
window.open('https://consumer.huawei.com/cn/harmonyos-computer/harmonyos-5/', '_blank')
|
window.open('https://consumer.huawei.com/cn/harmonyos-computer/harmonyos-5/', '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
// 预加载数据,在组件创建时就开始
|
||||||
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [today, downloads] = await Promise.all([
|
const [today, downloads] = await Promise.all([
|
||||||
appsApi.getTodayApps(20),
|
appsApi.getTodayApps(100),
|
||||||
appsApi.getTopDownloads(100)
|
appsApi.getTopDownloads(100)
|
||||||
])
|
])
|
||||||
|
|
||||||
if (today.success) {
|
if (today.success) {
|
||||||
todayApps.value = today.data
|
todayApps.value = today.data
|
||||||
console.log('今日上架应用数量:', todayApps.value.length)
|
|
||||||
}
|
}
|
||||||
if (downloads.success) {
|
if (downloads.success) {
|
||||||
topDownloads.value = downloads.data
|
topDownloads.value = downloads.data
|
||||||
console.log('热门应用数量:', topDownloads.value.length)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载数据失败:', error)
|
console.error('加载数据失败:', error)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即开始加载数据
|
||||||
|
loadData()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 如果数据还没加载完成,这里不需要做任何事
|
||||||
|
// 数据已经在 loadData() 中开始加载了
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -392,6 +427,72 @@ onMounted(async () => {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 骨架屏样式 */
|
||||||
|
.skeleton {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-box {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text {
|
||||||
|
height: 12px;
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-title {
|
||||||
|
width: 80%;
|
||||||
|
height: 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-subtitle {
|
||||||
|
width: 50%;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card.skeleton .app-icon {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card.skeleton .skeleton-text {
|
||||||
|
width: 90%;
|
||||||
|
height: 10px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item.skeleton {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item.skeleton:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item.skeleton .app-icon {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 响应式调整 */
|
/* 响应式调整 */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.explore-grid {
|
.explore-grid {
|
||||||
|
|||||||
349
frontend/src/views/HotApps.vue
Normal file
349
frontend/src/views/HotApps.vue
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<template>
|
||||||
|
<div class="hot-apps-page">
|
||||||
|
<div class="header">
|
||||||
|
<button @click="goBack" class="back-link" title="返回首页">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</button>
|
||||||
|
<h1>热门应用</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="isLoading" class="apps-grid">
|
||||||
|
<div v-for="i in 12" :key="`skeleton-${i}`" class="app-tile skeleton">
|
||||||
|
<div class="app-tile-content">
|
||||||
|
<div class="app-tile-icon skeleton-box"></div>
|
||||||
|
<div class="app-tile-info">
|
||||||
|
<div class="app-tile-header">
|
||||||
|
<div class="skeleton-text skeleton-title"></div>
|
||||||
|
<div class="skeleton-text skeleton-tag"></div>
|
||||||
|
</div>
|
||||||
|
<div class="app-tile-meta">
|
||||||
|
<div class="skeleton-text skeleton-version"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 应用列表 -->
|
||||||
|
<div v-else-if="apps.length > 0" class="apps-grid">
|
||||||
|
<div
|
||||||
|
v-for="app in apps"
|
||||||
|
:key="app.app_id"
|
||||||
|
class="app-tile"
|
||||||
|
@click="goToApp(app.app_id)"
|
||||||
|
>
|
||||||
|
<div class="app-tile-content">
|
||||||
|
<div class="app-tile-icon">
|
||||||
|
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="app-tile-info">
|
||||||
|
<div class="app-tile-header">
|
||||||
|
<h3>{{ app.name }}</h3>
|
||||||
|
<span class="category-tag">{{ app.kind_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="app-tile-meta">
|
||||||
|
<span v-if="app.version" class="version-tag">{{ app.version }}</span>
|
||||||
|
<span class="download-count">{{ formatDownloads(app.download_count) }}下载</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<i class="fas fa-inbox"></i>
|
||||||
|
<p>暂无热门应用</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { appsApi, type AppItem } from '@/api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const apps = ref<AppItem[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToApp = (appId: string) => {
|
||||||
|
router.push(`/app/${appId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDownloads = (count: number): string => {
|
||||||
|
if (count >= 100000000) return `${(count / 100000000).toFixed(1)}亿`
|
||||||
|
if (count >= 10000) return `${(count / 10000).toFixed(1)}万`
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadApps = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await appsApi.getTopDownloads(100)
|
||||||
|
if (res.success) {
|
||||||
|
apps.value = res.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载热门应用失败:', error)
|
||||||
|
apps.value = []
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadApps()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hot-apps-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 15px;
|
||||||
|
padding-top: 5px;
|
||||||
|
background: #F5F5F7;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f5f5f7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
background: #e5e5e7;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用网格 */
|
||||||
|
.apps-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-content {
|
||||||
|
padding: 15px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-icon img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-header h3 {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #007AFF;
|
||||||
|
background: #E3F2FD;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架屏 */
|
||||||
|
.skeleton {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-box {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-title {
|
||||||
|
height: 16px;
|
||||||
|
width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-tag {
|
||||||
|
height: 12px;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-version {
|
||||||
|
height: 12px;
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式布局 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.apps-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-apps-page {
|
||||||
|
padding: 10px;
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 10px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.app-tile-content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-header h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
412
frontend/src/views/NewApps.vue
Normal file
412
frontend/src/views/NewApps.vue
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
<template>
|
||||||
|
<div class="new-apps-page">
|
||||||
|
<div class="header">
|
||||||
|
<button @click="goBack" class="back-link" title="返回首页">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</button>
|
||||||
|
<h1>{{ dateText }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="date-switcher">
|
||||||
|
<button
|
||||||
|
@click="switchDate('today')"
|
||||||
|
:class="['date-btn', { active: currentDate === 'today' }]"
|
||||||
|
>
|
||||||
|
今日
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="switchDate('yesterday')"
|
||||||
|
:class="['date-btn', { active: currentDate === 'yesterday' }]"
|
||||||
|
>
|
||||||
|
昨日
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="switchDate('before_yesterday')"
|
||||||
|
:class="['date-btn', { active: currentDate === 'before_yesterday' }]"
|
||||||
|
>
|
||||||
|
前日
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="isLoading" class="apps-grid">
|
||||||
|
<div v-for="i in 12" :key="`skeleton-${i}`" class="app-tile skeleton">
|
||||||
|
<div class="app-tile-icon skeleton-box"></div>
|
||||||
|
<div class="app-tile-info">
|
||||||
|
<div class="skeleton-text"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 应用列表 -->
|
||||||
|
<div v-else-if="apps.length > 0" class="apps-grid">
|
||||||
|
<div
|
||||||
|
v-for="app in apps"
|
||||||
|
:key="app.app_id"
|
||||||
|
class="app-tile"
|
||||||
|
@click="goToApp(app.app_id)"
|
||||||
|
>
|
||||||
|
<div class="app-tile-content">
|
||||||
|
<div class="app-tile-icon">
|
||||||
|
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="app-tile-info">
|
||||||
|
<div class="app-tile-header">
|
||||||
|
<h3>{{ app.name }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<i class="fas fa-inbox"></i>
|
||||||
|
<p>{{ emptyText }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { appsApi, type AppItem } from '@/api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const apps = ref<AppItem[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const currentDate = ref<'today' | 'yesterday' | 'before_yesterday'>('today')
|
||||||
|
|
||||||
|
const dateText = computed(() => {
|
||||||
|
const dateMap = {
|
||||||
|
today: '今日上新',
|
||||||
|
yesterday: '昨日上新',
|
||||||
|
before_yesterday: '前日上新'
|
||||||
|
}
|
||||||
|
return dateMap[currentDate.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const emptyText = computed(() => {
|
||||||
|
const textMap = {
|
||||||
|
today: '今日暂无新上架应用',
|
||||||
|
yesterday: '昨日暂无新上架应用',
|
||||||
|
before_yesterday: '前日暂无新上架应用'
|
||||||
|
}
|
||||||
|
return textMap[currentDate.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToApp = (appId: string) => {
|
||||||
|
router.push(`/app/${appId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchDate = async (date: 'today' | 'yesterday' | 'before_yesterday') => {
|
||||||
|
currentDate.value = date
|
||||||
|
await loadApps()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadApps = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
// 根据选择的日期计算日期
|
||||||
|
const today = new Date()
|
||||||
|
let targetDate = new Date()
|
||||||
|
|
||||||
|
if (currentDate.value === 'yesterday') {
|
||||||
|
targetDate.setDate(today.getDate() - 1)
|
||||||
|
} else if (currentDate.value === 'before_yesterday') {
|
||||||
|
targetDate.setDate(today.getDate() - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期为 YYYY-MM-DD
|
||||||
|
const year = targetDate.getFullYear()
|
||||||
|
const month = String(targetDate.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(targetDate.getDate()).padStart(2, '0')
|
||||||
|
const dateStr = `${year}-${month}-${day}`
|
||||||
|
|
||||||
|
// 调用API获取指定日期的应用
|
||||||
|
const res = await appsApi.getAppsByDate(dateStr)
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
apps.value = res.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载应用失败:', error)
|
||||||
|
apps.value = []
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadApps()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.new-apps-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 15px;
|
||||||
|
padding-top: 5px;
|
||||||
|
background: #F5F5F7;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
background: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日期切换器 */
|
||||||
|
.date-switcher {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #666;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-btn:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-btn.active {
|
||||||
|
background: #007AFF;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用网格 */
|
||||||
|
.apps-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-icon img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-info {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
line-height: 1.3;
|
||||||
|
max-height: 2.6em;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架屏 */
|
||||||
|
.skeleton {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-box {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text {
|
||||||
|
height: 10px;
|
||||||
|
width: 90%;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式布局 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.apps-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-header h3 {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.new-apps-page {
|
||||||
|
padding: 10px;
|
||||||
|
padding-top: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 10px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-switcher {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tile-header h3 {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user