🎉 feat: initialize 宇之然AI维度 project
- uni-app Vue3 frontend with dark glassmorphism theme - 5 AI dimensions: Origin / Development / Current / Learning / Trend - AI Chat system prompt updated from geometry to AI - 23 AI knowledge articles initialized in DB - Trend API + cron job for daily news - Product pricing (Pro ¥19.9, VIP ¥39.9) - Pinia stores + API utilities - AGENTS.md documentation
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,54 @@
|
||||
# 宇之然AI维度 — 项目管理
|
||||
|
||||
## 目录结构
|
||||
```
|
||||
yuzhiran-ai-dimension/
|
||||
├── backend/ # 后端源码(从线上复制)
|
||||
│ └── wdkj-server/ # Node.js Express 项目
|
||||
│ └── src/ # 49 个源文件
|
||||
├── frontend/ # 前端项目(待创建)
|
||||
│ └── (uni-app 项目)
|
||||
├── design/
|
||||
│ └── ui-mockup.html # 移动端 UI 设计稿
|
||||
└── docs/
|
||||
├── product-plan.md # 产品规划文档
|
||||
└── api-assessment.md # API 评估与改造计划
|
||||
```
|
||||
|
||||
## 开发任务清单
|
||||
|
||||
### 阶段一:内容填充(1-2天)
|
||||
- [ ] 用管理后台给 Knowledge 库批量填充 AI 内容
|
||||
- dim1 AI 起源:10 篇
|
||||
- dim2 AI 发展:10 篇
|
||||
- dim3 AI 当前:10 篇
|
||||
- dim4 AI 学习:15 篇
|
||||
- dim5 AI 趋势:cron 自动推送
|
||||
- [ ] 修改 AIModel 配置,对接云帆网关
|
||||
|
||||
### 阶段二:后端改造(1天)
|
||||
- [ ] 改 AI Chat system prompt → AI 主题
|
||||
- [ ] 新增趋势资讯接口
|
||||
- [ ] 改商品数据(Pro/VIP 定价)
|
||||
|
||||
### 阶段三:前端开发(uni-app 全新,1-2周)
|
||||
- [ ] 初始化 uni-app 项目
|
||||
- [ ] 首页:5 维度卡片 + 今日快讯
|
||||
- [ ] 维度详情页:知识列表 + 问答
|
||||
- [ ] AI 问答页:对话界面 + 配额管理
|
||||
- [ ] 趋势页:资讯流
|
||||
- [ ] 个人中心:进度/收藏/订阅
|
||||
- [ ] 微信登录集成
|
||||
|
||||
### 阶段四:上架更新(1-3天)
|
||||
- [ ] 改小程序名:宇之然AI维度
|
||||
- [ ] 简称:AI维度
|
||||
- [ ] 提审微信更新
|
||||
|
||||
## 命名与品牌
|
||||
- 微信小程序全名:宇之然AI维度
|
||||
- 微信小程序简称:AI维度
|
||||
- 小程序 appid:wxdad62baf4ccd09e3
|
||||
- 后端主域名:wdkj.yuzhiran.com.cn → :3001
|
||||
- 管理后台:wdkjadmin.yzrcloud.cn
|
||||
- H5 地址:www.wdkj.yuzhiran.com.cn/h5/
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
logs/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,263 @@
|
||||
# 宇之然·星辰绘线 - 后端服务
|
||||
|
||||
## 项目简介
|
||||
|
||||
这是宇之然·星辰绘线项目的后端服务,提供 RESTful API 接口,支持微信小程序和后台管理系统。
|
||||
# 1. 查找占用指定端口的进程ID
|
||||
netstat -tunlp | grep :3001
|
||||
# 2. 根据进程ID kill进程
|
||||
kill -9 进程ID
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **运行时**: Node.js 18+
|
||||
- **框架**: Express.js 4.18
|
||||
- **数据库**: MongoDB (Mongoose ODM)
|
||||
- **认证**: JWT
|
||||
- **安全**: Helmet, CORS, Rate Limit
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
wdkj-server/
|
||||
├── src/
|
||||
│ ├── config/ # 配置文件
|
||||
│ │ └── database.js # 数据库连接
|
||||
│ ├── middleware/ # 中间件
|
||||
│ │ └── auth.js # 认证中间件
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── User.js # 用户模型
|
||||
│ │ ├── Order.js # 订单模型
|
||||
│ │ ├── Gallery.js # 画廊模型
|
||||
│ │ ├── Knowledge.js # 知识库模型
|
||||
│ │ ├── ShopItem.js # 商品模型
|
||||
│ │ ├── Admin.js # 管理员模型
|
||||
│ │ └── AdminLog.js # 操作日志模型
|
||||
│ ├── routes/ # 路由
|
||||
│ │ ├── auth.js # 认证路由
|
||||
│ │ ├── user.js # 用户路由
|
||||
│ │ ├── payment.js # 支付路由
|
||||
│ │ ├── gallery.js # 画廊路由
|
||||
│ │ ├── knowledge.js # 知识库路由
|
||||
│ │ ├── admin.js # 管理后台路由
|
||||
│ │ └── index.js # 路由汇总
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── response.js # 统一响应
|
||||
│ │ ├── logger.js # 日志工具
|
||||
│ │ └── weixin.js # 微信API工具
|
||||
│ └── index.js # 主入口
|
||||
├── logs/ # 日志目录
|
||||
├── .env # 环境变量
|
||||
├── package.json # 项目配置
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd wdkj-server
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
复制 `.env.example` 为 `.env` 并修改配置:
|
||||
|
||||
```env
|
||||
# 服务配置
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# MongoDB 配置
|
||||
MONGODB_URI=mongodb://wdkj123:wdkj123@192.168.136.130:27017/wdkj?authSource=wdkj
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=your_secret_key_here
|
||||
JWT_EXPIRE=7d
|
||||
|
||||
# 微信小程序配置
|
||||
WX_APPID=wxdad62baf4ccd09e3
|
||||
WX_SECRET=your_wx_app_secret
|
||||
|
||||
# 微信支付配置
|
||||
WX_PAY_MCH_ID=1108945993
|
||||
WX_PAY_API_KEY=your_apiv3_key
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
npm run dev
|
||||
|
||||
# 生产环境
|
||||
npm start
|
||||
```
|
||||
|
||||
### 4. 验证服务
|
||||
|
||||
访问健康检查接口:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
### 认证相关
|
||||
|
||||
- `POST /api/auth/login` - 小程序登录
|
||||
- `POST /api/auth/admin/login` - 管理员登录
|
||||
- `GET /api/auth/me` - 获取当前用户信息
|
||||
|
||||
### 用户相关
|
||||
|
||||
- `GET /api/user/progress` - 获取用户进度
|
||||
- `POST /api/user/progress` - 保存用户进度
|
||||
- `GET /api/user/leaderboard` - 获取排行榜
|
||||
|
||||
### 支付相关
|
||||
|
||||
- `POST /api/payment/create-order` - 创建订单
|
||||
- `POST /api/payment/callback` - 支付回调
|
||||
- `POST /api/payment/verify` - 验证支付
|
||||
- `GET /api/payment/products` - 获取商品列表
|
||||
|
||||
### 画廊相关
|
||||
|
||||
- `GET /api/gallery` - 获取作品列表
|
||||
- `POST /api/gallery` - 上传作品
|
||||
- `GET /api/gallery/:id` - 获取作品详情
|
||||
- `POST /api/gallery/:id/like` - 点赞作品
|
||||
|
||||
### 知识库相关
|
||||
|
||||
- `GET /api/knowledge` - 获取知识列表
|
||||
- `GET /api/knowledge/:id` - 获取知识详情
|
||||
- `POST /api/knowledge/:id/collect` - 收藏知识
|
||||
- `GET /api/knowledge/:dim/questions` - 获取问答
|
||||
|
||||
### 管理后台
|
||||
|
||||
- `GET /api/admin/dashboard` - 获取仪表盘数据
|
||||
- `GET /api/admin/users` - 获取用户列表
|
||||
- `GET /api/admin/users/:id` - 获取用户详情
|
||||
- `GET /api/admin/knowledge` - 获取知识列表
|
||||
- `POST /api/admin/knowledge` - 创建/更新知识
|
||||
- `POST /api/admin/knowledge/review` - 审核知识
|
||||
- `GET /api/admin/gallery` - 获取画廊作品
|
||||
- `POST /api/admin/gallery/review` - 审核作品
|
||||
- `GET /api/admin/orders` - 获取订单列表
|
||||
- `GET /api/admin/shop` - 获取商品列表
|
||||
- `POST /api/admin/shop` - 保存商品
|
||||
- `GET /api/admin/logs` - 获取操作日志
|
||||
|
||||
## 数据库初始化
|
||||
|
||||
首次启动服务时,需要初始化管理员账户:
|
||||
|
||||
```javascript
|
||||
// 可以创建一个初始化脚本
|
||||
const Admin = require('./src/models/Admin')
|
||||
|
||||
async function initAdmin() {
|
||||
const admin = new Admin({
|
||||
username: 'admin',
|
||||
password: 'admin123456',
|
||||
email: 'admin@wdkj.com',
|
||||
role: 'super_admin',
|
||||
permissions: ['*']
|
||||
})
|
||||
await admin.save()
|
||||
}
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 代码规范
|
||||
|
||||
- 使用 ES6+ 语法
|
||||
- 异步操作使用 async/await
|
||||
- 错误统一通过 ApiResponse 处理
|
||||
- 所有操作记录日志
|
||||
|
||||
### 日志系统
|
||||
|
||||
日志级别:
|
||||
- `debug`: 调试信息
|
||||
- `info`: 常规信息
|
||||
- `warn`: 警告信息
|
||||
- `error`: 错误信息
|
||||
|
||||
使用示例:
|
||||
|
||||
```javascript
|
||||
const logger = require('./utils/logger')
|
||||
|
||||
logger.info('用户登录', { openid: 'xxx' })
|
||||
logger.error('数据库错误', error)
|
||||
```
|
||||
|
||||
### 权限系统
|
||||
|
||||
角色权限矩阵:
|
||||
|
||||
| 角色 | 权限范围 |
|
||||
|------|----------|
|
||||
| super_admin | 所有权限 (*) |
|
||||
| content_manager | 知识库、画廊、用户查看 |
|
||||
| shop_manager | 商品、订单、用户查看 |
|
||||
| viewer | 仪表盘、分析、用户查看 |
|
||||
|
||||
## 生产部署
|
||||
|
||||
### 使用 PM2
|
||||
|
||||
```bash
|
||||
# 安装 PM2
|
||||
npm install -g pm2
|
||||
|
||||
# 启动服务
|
||||
pm2 start src/index.js --name wdkj-server
|
||||
|
||||
# 查看状态
|
||||
pm2 status
|
||||
|
||||
# 查看日志
|
||||
pm2 logs wdkj-server
|
||||
```
|
||||
|
||||
### 使用 Docker
|
||||
|
||||
创建 `Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
```
|
||||
|
||||
构建并运行:
|
||||
|
||||
```bash
|
||||
docker build -t wdkj-server .
|
||||
docker run -p 3000:3000 -d wdkj-server
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **安全**: 生产环境必须修改 JWT_SECRET
|
||||
2. **数据库**: 确保 MongoDB 已正确配置访问权限
|
||||
3. **支付**: 微信支付需要真实的商户证书和密钥
|
||||
4. **日志**: 生产环境会自动写入日志文件
|
||||
5. **限流**: 已配置 API 请求频率限制
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -0,0 +1,25 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIENDCCAxygAwIBAgIUYUzuDMy950xrnuZnUV3V4gBjtKQwDQYJKoZIhvcNAQEL
|
||||
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
|
||||
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
|
||||
Q0EwHhcNMjYwMzI0MDYzNTUxWhcNMzEwMzIzMDYzNTUxWjCBjTETMBEGA1UEAwwK
|
||||
MTEwODk0NTk5MzEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTkwNwYDVQQL
|
||||
DDDljJfkuqzlrofkuYvnhLbnp5HmioDkuK3lv4PvvIjkuKrkvZPlt6XllYbmiLfv
|
||||
vIkxCzAJBgNVBAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcN
|
||||
AQEBBQADggEPADCCAQoCggEBANKG2DnPUqF4kP/IErd0z7MejTXhfRT/9shGgNUm
|
||||
SFQmK5Vb7owQyFORj5Y22r5Xa+g7t0Wjwmc8jjIFiIIUickf9IHedDzpclK6nPcf
|
||||
whgEQ5YolA0yRSujlBdpDKxiZED/OdiF+oRNW3Orl4dC2eb/+yCvjr9IgXRwnn4l
|
||||
ODP8DQ4/xW6JnQhyy+yQ4vydhkK1G1aaqS3kQAyQopgwcGif1e9Wo6JI1c+1MF98
|
||||
4KNLEVABg9sH2yRMYSsspyvJfdu+FA9oZT5LXGjSrdyaq7t8cESqRzhQMLvRcZ3o
|
||||
QSbY9EE9Xy0dt9URS7ZdK53MwoRpnQsUSwpWT8r07f9sM1ECAwEAAaOBuTCBtjAJ
|
||||
BgNVHRMEAjAAMAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGE
|
||||
aHR0cDovL2V2Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0
|
||||
MjIwRTUwREJDMDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFC
|
||||
NjU0MjJFMTJCMjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEB
|
||||
CwUAA4IBAQCXQjHcCkJh/Wr3qvi5q3yBXA/zUchZOtBh220dGdEvR+L+9s9i7aGf
|
||||
legNT0+E1AakD/W6TC25pL7oK3QmyjdW+JGdNtfVh5v+uUNu333mRPre5Bj94cfJ
|
||||
9W7EHgfzEi3MJ3HXBclh7pCyxl6FRXXXj7STeBetVH0njvYRV6nOl7wobW77lE5N
|
||||
JkUYPVPPd5pbS3BSpykBS4zxtaBK2MtzhhELonAbzib2z0edlwlGoJD0BFAr7BGT
|
||||
00mM95Hz5I3W4UgHvv+mDlOMjBAwCtaDSSCoJDhiaxs/WEru4jSqY4aiKEsVrtAQ
|
||||
zcR9dNa21f+uzO6/oziswYstXOkzeVoc
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDShtg5z1KheJD/
|
||||
yBK3dM+zHo014X0U//bIRoDVJkhUJiuVW+6MEMhTkY+WNtq+V2voO7dFo8JnPI4y
|
||||
BYiCFInJH/SB3nQ86XJSupz3H8IYBEOWKJQNMkUro5QXaQysYmRA/znYhfqETVtz
|
||||
q5eHQtnm//sgr46/SIF0cJ5+JTgz/A0OP8VuiZ0IcsvskOL8nYZCtRtWmqkt5EAM
|
||||
kKKYMHBon9XvVqOiSNXPtTBffOCjSxFQAYPbB9skTGErLKcryX3bvhQPaGU+S1xo
|
||||
0q3cmqu7fHBEqkc4UDC70XGd6EEm2PRBPV8tHbfVEUu2XSudzMKEaZ0LFEsKVk/K
|
||||
9O3/bDNRAgMBAAECggEBAJdPgZMzNmGFpTmhlAo1M566o6WJNnxkZ/uT6L7J8AxD
|
||||
Duofk/kpisG9ieTd8iQB8zWLzfKIe431kQHUxkMv/cQHapX4y7SH2LorE+vt8HOQ
|
||||
hP8klRxS+DIgNK0KvKgdY47voaTEzsROUR92wcbevnxvmQ/p/f/vXdPRqahimd2J
|
||||
yZhIizb+H/d9ua5co5icVcupknK+mBna5uDqa/hoYPNVXmvzF4I1bZFeylN3v9CE
|
||||
3YQt3qZ9OPPHl4zTQWkx7c2c+ELzOC9xWanUhfw41vtuv3hreZBZp9+jJpP5z9P7
|
||||
Rr1LCom7RtDNW0Py18p7GX3jAmjLoIehfqnIdjN0aU0CgYEA9ermlfZAP4W2MH+v
|
||||
t97xlXn6JWBC9Mwlu7xqEJlfrdk8c/z5krBOoc+11WZoUZdtMlho1R8l4qn4GWUG
|
||||
VbAQJm/M2kdMVTmDWnx/2DiCrqH07mjxf6UgX1kUwiBU4YZTI+qWSV9tzf7mXtpD
|
||||
mupOnRInA9urvGocUgThllaCXasCgYEA2yh9McFKrkUZ6v9HFXekLWTjpVV2xXYU
|
||||
4l7UJS8oMHMrIftP4O6EAvkOH6ZP1Ip61KQACfe0j0TiiXrLvpNXmxEe9gfaWTZY
|
||||
OjYeGy0sdJQ8ByXzsaguPDI20qMn56nluDRWgh9JqIUB5ZYSE84wh9hhEJdJEi0a
|
||||
uQVQusxZ3vMCgYBQcGa+dM8tOBKRKKMUtPM23rBJG4SrSD+regUDqveWCTHyCrSk
|
||||
G9GMskGbLSVAUxTf2/VmiQq+arSBsf7xdmbB+935JEs+sVJa/dBfrJRqhQV2GpOs
|
||||
GhpNtfjJSwQYxPRbEjfYhkOHlzJJooFsoenXDQIADBHPzrG2zmvv3hpEgwKBgDAd
|
||||
L38g5HhmC06gRMbdwVmF8MR3gt/PEL6x+vk+5R/d6PZI6jyDoM/WRdeE97m9vUG5
|
||||
z5hgnW9mvCVapChuyKjulVGGX9V+LNh60Rk+Tag6cwNOvbWfKqdkjsynrTcbAafz
|
||||
NZrTq6Qnr0bsL4PRnK32PzkZPZ2T8W31UMfx3TkpAoGBANuTZsojCvyL1ZfcB4P8
|
||||
Cf+Xv6CU5165DE+q+Sj3/HDrmDnDD9J4B0TlXj1803dydz4ZsC2BJqmCDXQGFI09
|
||||
FbQV3AIGE4XI2GWIC9f4+0W1FfAUp0Y2I5p7OMl4eN7RKh0PHCajN5pM534cZ0k/
|
||||
TQHNwOuaI56juLP4TIMKDPGk
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzK+YibPERKTi8CaO+Yq
|
||||
meTdJmaTqHTS/XBbnRmZgQmudwPIfybp1E+wPKixcBrw/qaj6Ewfoo+6eh++aUVw
|
||||
ImiQcmYrF+4IgpaTJMfYwYgyeig8XlGlwNNngWPZ7g+7Q7FZUjMYJ4ITq2IHCyWR
|
||||
IhQfPY9auZdBAndtcX9flDdUAzur2EDYVNYMlFMxt2wGnBGfXitsZefWdvRq5IA1
|
||||
N8zCxfCRvN9GFBjtjQXKeUmPwxLmcqKuA3fRz/THjq0oeggQK9PNQmtZgUD8nKE7
|
||||
cils+CFpkLK+a34iFIA2QHygL6itReyV7O47MD4gYYW+aCmp/L0FUcdISJtaCZ2T
|
||||
9QIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/**/*.test.js'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.js',
|
||||
'!src/index.js'
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
testTimeout: 30000,
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
||||
verbose: true
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "wdkj-server",
|
||||
"version": "1.0.0",
|
||||
"description": "宇之然-空间探索 - 后端服务",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"miniprogram",
|
||||
"geometry",
|
||||
"education"
|
||||
],
|
||||
"author": "宇之然团队",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"mongoose": "^8.0.3",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"supertest": "^7.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo 运行单元测试和集成测试
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
cd %~dp0
|
||||
|
||||
echo [1/3] 设置测试环境...
|
||||
set NODE_ENV=test
|
||||
|
||||
echo [2/3] 运行测试...
|
||||
call npm test
|
||||
|
||||
echo.
|
||||
echo [3/3] 生成测试报告...
|
||||
echo 测试完成!
|
||||
|
||||
pause
|
||||
@@ -0,0 +1,37 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo MongoDB 用户创建工具
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 请在 MongoDB 服务器上执行以下命令:
|
||||
echo.
|
||||
echo 1. 连接到 MongoDB:
|
||||
echo mongo --host 192.168.136.130 --port 27017
|
||||
echo.
|
||||
echo 2. 切换到 admin 数据库:
|
||||
echo use admin
|
||||
echo.
|
||||
echo 3. 创建管理员用户(如果还没有):
|
||||
echo db.createUser({
|
||||
echo user: "admin",
|
||||
echo pwd: "admin123",
|
||||
echo roles: ["root"]
|
||||
echo })
|
||||
echo.
|
||||
echo 4. 切换到 wdkj 数据库:
|
||||
echo use wdkj
|
||||
echo.
|
||||
echo 5. 创建 wdkj 用户:
|
||||
echo db.createUser({
|
||||
echo user: "wdkj123",
|
||||
echo pwd: "wdkj123",
|
||||
echo roles: [
|
||||
echo { role: "readWrite", db: "wdkj" }
|
||||
echo ]
|
||||
echo })
|
||||
echo.
|
||||
echo 6. 验证用户:
|
||||
echo db.auth("wdkj123", "wdkj123")
|
||||
echo.
|
||||
echo ========================================
|
||||
pause
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 快速修复BGM URL - 使用测试音频
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose')
|
||||
require('dotenv').config()
|
||||
|
||||
const { BGM } = require('../src/models')
|
||||
|
||||
// 免费的测试音频URL (SoundHelix - 可商用)
|
||||
const TEST_AUDIO_URL = 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
|
||||
|
||||
async function fixBGMUrls() {
|
||||
try {
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/yuzhiran')
|
||||
console.log('✅ 数据库连接成功')
|
||||
|
||||
// 更新所有BGM的URL为测试音频
|
||||
const result = await BGM.updateMany(
|
||||
{},
|
||||
{ url: TEST_AUDIO_URL }
|
||||
)
|
||||
|
||||
console.log(`✅ 已更新 ${result.modifiedCount} 条BGM记录`)
|
||||
console.log(` 新URL: ${TEST_AUDIO_URL}`)
|
||||
console.log('\n💡 现在可以测试BGM播放功能了')
|
||||
console.log('⚠️ 注意: 这是测试音频,生产环境请替换为正式音乐')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 更新失败:', error)
|
||||
} finally {
|
||||
await mongoose.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fixBGMUrls()
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* BGM音效初始化脚本
|
||||
* 为每个维度添加示例背景音乐
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose')
|
||||
require('dotenv').config()
|
||||
|
||||
// 导入模型
|
||||
const { BGM } = require('../src/models')
|
||||
|
||||
// 示例BGM数据(实际使用时需要替换为真实的音频URL)
|
||||
const sampleBGMs = [
|
||||
// 一维 - 线性旋律
|
||||
{
|
||||
name: '星光轨迹',
|
||||
dimension: 1,
|
||||
url: 'https://example.com/bgm/dim1_starlight.mp3',
|
||||
duration: 120,
|
||||
loop: true,
|
||||
volume: 0.4,
|
||||
sortOrder: 1,
|
||||
description: '轻柔的线性旋律,适合一维空间的探索',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
name: '弦之舞',
|
||||
dimension: 1,
|
||||
url: 'https://example.com/bgm/dim1_string_dance.mp3',
|
||||
duration: 95,
|
||||
loop: true,
|
||||
volume: 0.45,
|
||||
sortOrder: 2,
|
||||
description: '活泼的弦乐节奏',
|
||||
isActive: true
|
||||
},
|
||||
|
||||
// 二维 - 平面和声
|
||||
{
|
||||
name: '星座幻想',
|
||||
dimension: 2,
|
||||
url: 'https://example.com/bgm/dim2_constellation.mp3',
|
||||
duration: 150,
|
||||
loop: true,
|
||||
volume: 0.5,
|
||||
sortOrder: 1,
|
||||
description: '梦幻的星空音乐,配合二维星座绘制',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
name: '几何韵律',
|
||||
dimension: 2,
|
||||
url: 'https://example.com/bgm/dim2_geometry.mp3',
|
||||
duration: 110,
|
||||
loop: true,
|
||||
volume: 0.48,
|
||||
sortOrder: 2,
|
||||
description: '轻快的几何节奏',
|
||||
isActive: true
|
||||
},
|
||||
|
||||
// 三维 - 立体环绕
|
||||
{
|
||||
name: '立方体回响',
|
||||
dimension: 3,
|
||||
url: 'https://example.com/bgm/dim3_cube_echo.mp3',
|
||||
duration: 180,
|
||||
loop: true,
|
||||
volume: 0.5,
|
||||
sortOrder: 1,
|
||||
description: '空间感强烈的立体音效',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
name: '多维共振',
|
||||
dimension: 3,
|
||||
url: 'https://example.com/bgm/dim3_resonance.mp3',
|
||||
duration: 140,
|
||||
loop: true,
|
||||
volume: 0.52,
|
||||
sortOrder: 2,
|
||||
description: '富有层次感的三维音效',
|
||||
isActive: true
|
||||
},
|
||||
|
||||
// 四维 - 时间流动
|
||||
{
|
||||
name: '时空涟漪',
|
||||
dimension: 4,
|
||||
url: 'https://example.com/bgm/dim4_spacetime.mp3',
|
||||
duration: 200,
|
||||
loop: true,
|
||||
volume: 0.45,
|
||||
sortOrder: 1,
|
||||
description: '神秘的四维时空音乐',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
name: '超立方漫游',
|
||||
dimension: 4,
|
||||
url: 'https://example.com/bgm/dim4_tesseract.mp3',
|
||||
duration: 165,
|
||||
loop: true,
|
||||
volume: 0.47,
|
||||
sortOrder: 2,
|
||||
description: '穿越维度的奇妙旅程',
|
||||
isActive: true
|
||||
},
|
||||
|
||||
// 五维 - 高维混沌
|
||||
{
|
||||
name: '思维风暴',
|
||||
dimension: 5,
|
||||
url: 'https://example.com/bgm/dim5_thought_storm.mp3',
|
||||
duration: 220,
|
||||
loop: true,
|
||||
volume: 0.5,
|
||||
sortOrder: 1,
|
||||
description: '复杂而迷人的高维音效',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
name: '量子纠缠',
|
||||
dimension: 5,
|
||||
url: 'https://example.com/bgm/dim5_quantum.mp3',
|
||||
duration: 190,
|
||||
loop: true,
|
||||
volume: 0.48,
|
||||
sortOrder: 2,
|
||||
description: '微观世界的奇妙声音',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
|
||||
async function initBGM() {
|
||||
try {
|
||||
// 连接数据库
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/yuzhiran')
|
||||
console.log('✅ 数据库连接成功')
|
||||
|
||||
// 检查是否已有BGM数据
|
||||
const existingCount = await BGM.countDocuments()
|
||||
if (existingCount > 0) {
|
||||
console.log(`⚠️ 数据库中已有 ${existingCount} 条BGM记录,跳过初始化`)
|
||||
await mongoose.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
// 插入示例数据
|
||||
const result = await BGM.insertMany(sampleBGMs)
|
||||
console.log(`✅ 成功初始化 ${result.length} 条BGM记录`)
|
||||
|
||||
// 按维度统计
|
||||
for (let dim = 1; dim <= 5; dim++) {
|
||||
const count = await BGM.countDocuments({ dimension: dim })
|
||||
console.log(` 维度${dim}: ${count} 首`)
|
||||
}
|
||||
|
||||
console.log('\n🎵 BGM初始化完成!')
|
||||
console.log('💡 提示:请在后台管理系统中上传真实的音频文件并更新URL')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ BGM初始化失败:', error)
|
||||
} finally {
|
||||
await mongoose.disconnect()
|
||||
console.log('数据库连接已关闭')
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initBGM()
|
||||
@@ -0,0 +1,218 @@
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') })
|
||||
const mongoose = require('mongoose')
|
||||
const { Admin, ShopItem, Knowledge, Gallery } = require('../src/models')
|
||||
|
||||
/**
|
||||
* 数据库初始化脚本
|
||||
*/
|
||||
async function initDatabase() {
|
||||
try {
|
||||
// 连接数据库
|
||||
const mongoURI = process.env.MONGODB_URI
|
||||
await mongoose.connect(mongoURI)
|
||||
console.log('✅ 数据库连接成功')
|
||||
|
||||
// 1. 创建超级管理员
|
||||
console.log('\n📋 创建超级管理员...')
|
||||
const existingAdmin = await Admin.findOne({ username: 'admin' })
|
||||
|
||||
if (!existingAdmin) {
|
||||
const admin = new Admin({
|
||||
username: process.env.ADMIN_USERNAME || 'admin',
|
||||
password: process.env.ADMIN_PASSWORD || 'admin123456',
|
||||
email: process.env.ADMIN_EMAIL || 'admin@wdkj.com',
|
||||
realName: '超级管理员',
|
||||
role: 'super_admin',
|
||||
permissions: ['*'],
|
||||
status: 'active'
|
||||
})
|
||||
await admin.save()
|
||||
console.log('✅ 超级管理员创建成功')
|
||||
console.log(` 用户名: ${admin.username}`)
|
||||
console.log(` 密码: ${process.env.ADMIN_PASSWORD || 'admin123456'}`)
|
||||
} else {
|
||||
console.log('⚠️ 管理员账户已存在')
|
||||
}
|
||||
|
||||
// 2. 初始化商品
|
||||
console.log('\n📋 初始化商品...')
|
||||
const products = [
|
||||
{
|
||||
itemId: 'skin_quantum',
|
||||
name: '量子线皮肤',
|
||||
description: '一维世界专属皮肤,让你的线条闪耀量子光芒',
|
||||
price: 6,
|
||||
type: 'skin',
|
||||
status: 'active',
|
||||
sortOrder: 1
|
||||
},
|
||||
{
|
||||
itemId: 'skin_hologram',
|
||||
name: '全息面皮肤',
|
||||
description: '二维世界专属皮肤,让你的图形绽放全息色彩',
|
||||
price: 18,
|
||||
type: 'skin',
|
||||
status: 'active',
|
||||
sortOrder: 2
|
||||
},
|
||||
{
|
||||
itemId: 'skin_nebula',
|
||||
name: '星云体皮肤',
|
||||
description: '三维世界专属皮肤,让你的立方体流光溢彩',
|
||||
price: 30,
|
||||
type: 'skin',
|
||||
status: 'active',
|
||||
sortOrder: 3
|
||||
},
|
||||
{
|
||||
itemId: 'no_ads',
|
||||
name: '永久去广告',
|
||||
description: '一键去除所有广告,享受纯净的维度探索之旅。',
|
||||
price: 1200,
|
||||
type: 'ad_free',
|
||||
status: 'active',
|
||||
sortOrder: 4
|
||||
},
|
||||
{
|
||||
itemId: 'monthly_sub',
|
||||
name: '星云月卡',
|
||||
description: '尊享月度特权:全皮肤免费用、得分翻倍、专属身份标识。',
|
||||
price: 1800,
|
||||
type: 'vip',
|
||||
duration: 30,
|
||||
status: 'active',
|
||||
sortOrder: 5
|
||||
}
|
||||
]
|
||||
|
||||
for (const product of products) {
|
||||
const existing = await ShopItem.findOne({ itemId: product.itemId })
|
||||
if (!existing) {
|
||||
await ShopItem.create(product)
|
||||
console.log(` ✅ ${product.name} 创建成功`)
|
||||
} else {
|
||||
console.log(` ⚠️ ${product.name} 已存在`)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 初始化知识库示例
|
||||
console.log('\n📋 初始化知识库示例...')
|
||||
const knowledgeList = [
|
||||
{
|
||||
title: '什么是维度?',
|
||||
content: '维度是描述空间独立方向的参数。零维是点,一维是线,二维是面,三维是体。',
|
||||
dim: 1,
|
||||
category: 'concept',
|
||||
tags: ['基础概念', '维度'],
|
||||
status: 'approved',
|
||||
sortOrder: 1
|
||||
},
|
||||
{
|
||||
title: '一维世界:线的宇宙',
|
||||
content: '一维世界只有长度这一个维度,就像一条无限延伸的直线。生活在一维世界的生物只能前进或后退。',
|
||||
dim: 1,
|
||||
category: 'concept',
|
||||
tags: ['一维', '线'],
|
||||
status: 'approved',
|
||||
sortOrder: 2
|
||||
},
|
||||
{
|
||||
title: '二维世界:平面王国',
|
||||
content: '二维世界有长度和宽度两个维度,就像一张无限大的纸。平面国中的生物可以在平面上自由移动。',
|
||||
dim: 2,
|
||||
category: 'concept',
|
||||
tags: ['二维', '平面'],
|
||||
status: 'approved',
|
||||
sortOrder: 1
|
||||
},
|
||||
{
|
||||
title: '三维世界:立体空间',
|
||||
content: '三维世界有长度、宽度和高度三个维度,这正是我们生活的世界。我们可以前后、左右、上下移动。',
|
||||
dim: 3,
|
||||
category: 'concept',
|
||||
tags: ['三维', '立体'],
|
||||
status: 'approved',
|
||||
sortOrder: 1
|
||||
}
|
||||
]
|
||||
|
||||
for (const knowledge of knowledgeList) {
|
||||
const existing = await Knowledge.findOne({ title: knowledge.title })
|
||||
if (!existing) {
|
||||
await Knowledge.create(knowledge)
|
||||
console.log(` ✅ ${knowledge.title} 创建成功`)
|
||||
} else {
|
||||
console.log(` ⚠️ ${knowledge.title} 已存在`)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 初始化画廊示例作品
|
||||
console.log('\n📋 初始化画廊示例作品...')
|
||||
const galleryWorks = [
|
||||
{
|
||||
title: '一维·星点轨迹',
|
||||
description: '在一维世界中,点动成线。这是我探索宇宙起点的轨迹记录。',
|
||||
imageData: '/static/images/gallery_demo_dim1.png',
|
||||
dim: 1,
|
||||
openid: 'system_admin',
|
||||
authorName: '系统管理员',
|
||||
authorAvatar: '/static/images/default_avatar.png',
|
||||
likeCount: 128,
|
||||
tags: ['一维', '轨迹', '起点'],
|
||||
status: 'approved',
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
title: '二维·星座图腾',
|
||||
description: '连接星体,绘制出属于我的平面王国图腾。',
|
||||
imageData: '/static/images/gallery_demo_dim2.png',
|
||||
dim: 2,
|
||||
openid: 'system_admin',
|
||||
authorName: '星辰旅者',
|
||||
authorAvatar: '/static/images/default_avatar.png',
|
||||
likeCount: 96,
|
||||
tags: ['二维', '星座', '图腾'],
|
||||
status: 'approved',
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
title: '三维·流光立方',
|
||||
description: '旋转的立方体展现了立体几何的魅力,色彩在空间中流动。',
|
||||
imageData: '/static/images/gallery_demo_dim3.png',
|
||||
dim: 3,
|
||||
openid: 'system_admin',
|
||||
authorName: '维度探索者',
|
||||
authorAvatar: '/static/images/default_avatar.png',
|
||||
likeCount: 215,
|
||||
tags: ['三维', '立方体', '流光'],
|
||||
status: 'approved',
|
||||
createdAt: new Date()
|
||||
}
|
||||
]
|
||||
|
||||
for (const work of galleryWorks) {
|
||||
const existing = await Gallery.findOne({ title: work.title })
|
||||
if (!existing) {
|
||||
await Gallery.create(work)
|
||||
console.log(` ✅ ${work.title} 创建成功`)
|
||||
} else {
|
||||
console.log(` ⚠️ ${work.title} 已存在`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ 数据库初始化完成!')
|
||||
console.log('\n📝 管理员登录信息:')
|
||||
console.log(` 用户名: ${process.env.ADMIN_USERNAME || 'admin'}`)
|
||||
console.log(` 密码: ${process.env.ADMIN_PASSWORD || 'admin123456'}`)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化失败:', error)
|
||||
} finally {
|
||||
await mongoose.connection.close()
|
||||
console.log('\n🔌 数据库连接已关闭')
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initDatabase()
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 初始化知识数据脚本
|
||||
* 用于填充测试知识内容
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose')
|
||||
require('dotenv').config()
|
||||
|
||||
// 导入知识模型
|
||||
const Knowledge = require('../src/models/Knowledge')
|
||||
|
||||
// 测试知识数据
|
||||
const testKnowledgeData = [
|
||||
// 一维知识
|
||||
{
|
||||
title: '一维空间的基本概念',
|
||||
content: '一维空间是数学中最简单的空间形式,它只有长度这一个维度。在物理学中,一维空间可以用来描述直线运动,比如物体在直线上来回运动。一维空间的特点是所有点都在同一条直线上,没有宽度和高度。',
|
||||
dim: 1,
|
||||
category: 'concept',
|
||||
tags: ['一维', '空间', '数学'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 0,
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
title: '一维空间的数学表示',
|
||||
content: '在数学中,一维空间通常用实数轴来表示。实数轴上的每个点对应一个实数,点与点之间的距离就是它们对应实数的差的绝对值。一维空间中的向量只有一个分量,可以用一个实数来表示。',
|
||||
dim: 1,
|
||||
category: 'application',
|
||||
tags: ['一维', '数学', '实数轴'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 0,
|
||||
price: 0
|
||||
},
|
||||
|
||||
// 二维知识
|
||||
{
|
||||
title: '二维空间的几何特性',
|
||||
content: '二维空间具有两个维度:长度和宽度。在二维空间中,我们可以定义平面几何图形,如点、线、三角形、圆形等。二维空间中的每个点可以用两个坐标(x, y)来表示。',
|
||||
dim: 2,
|
||||
category: 'concept',
|
||||
tags: ['二维', '几何', '平面'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 10,
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
title: '二维坐标系的应用',
|
||||
content: '二维坐标系在计算机图形学、地理信息系统、工程设计等领域有广泛应用。笛卡尔坐标系是最常见的二维坐标系,它使用相互垂直的x轴和y轴来定位平面上的点。',
|
||||
dim: 2,
|
||||
category: 'application',
|
||||
tags: ['二维', '坐标系', '应用'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 20,
|
||||
price: 0
|
||||
},
|
||||
|
||||
// 三维知识
|
||||
{
|
||||
title: '理解三维空间',
|
||||
content: '三维空间是我们最熟悉的空间形式,它具有长度、宽度和高度三个维度。在三维空间中,物体具有体积,可以定义立体几何图形如立方体、球体、圆柱体等。',
|
||||
dim: 3,
|
||||
category: 'concept',
|
||||
tags: ['三维', '空间', '立体'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 30,
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
title: '三维建模技术',
|
||||
content: '三维建模技术在动画制作、游戏开发、建筑设计等领域有重要应用。常见的三维建模方法包括多边形建模、NURBS建模、体素建模等。',
|
||||
dim: 3,
|
||||
category: 'application',
|
||||
tags: ['三维', '建模', '技术'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 40,
|
||||
price: 0
|
||||
},
|
||||
|
||||
// 四维知识(付费内容)
|
||||
{
|
||||
title: '四维时空的概念',
|
||||
content: '四维时空是爱因斯坦相对论中的核心概念,它将三维空间与时间维度结合。在四维时空中,事件的发生不仅取决于空间位置,还取决于时间点。四维时空的几何特性由闵可夫斯基度规描述。',
|
||||
dim: 4,
|
||||
category: 'concept',
|
||||
tags: ['四维', '时空', '相对论'],
|
||||
status: 'approved',
|
||||
isPremium: true,
|
||||
requiredPoints: 0,
|
||||
price: 9.9
|
||||
},
|
||||
{
|
||||
title: '四维空间的可视化',
|
||||
content: '由于人类无法直接感知四维空间,科学家们开发了多种可视化技术。常见的方法包括投影法、切片法、颜色编码法等,帮助我们理解高维空间的特性。',
|
||||
dim: 4,
|
||||
category: 'application',
|
||||
tags: ['四维', '可视化', '高维'],
|
||||
status: 'approved',
|
||||
isPremium: true,
|
||||
requiredPoints: 0,
|
||||
price: 12.9
|
||||
},
|
||||
|
||||
// 五维知识(高级付费内容)
|
||||
{
|
||||
title: '五维空间的物理意义',
|
||||
content: '五维空间理论在弦理论和卡鲁扎-克莱因理论中有重要应用。在五维空间中,除了三维空间和一维时间外,还可能存在额外的紧致维度。这些理论试图统一引力与其他基本力。',
|
||||
dim: 5,
|
||||
category: 'concept',
|
||||
tags: ['五维', '弦理论', '统一理论'],
|
||||
status: 'approved',
|
||||
isPremium: true,
|
||||
requiredPoints: 0,
|
||||
price: 19.9
|
||||
},
|
||||
{
|
||||
title: '高维空间的数学基础',
|
||||
content: '高维空间的数学研究涉及线性代数、微分几何、拓扑学等多个领域。n维空间中的点可以用n个坐标表示,距离和角度等概念可以通过推广低维空间的定义来建立。',
|
||||
dim: 5,
|
||||
category: 'application',
|
||||
tags: ['高维', '数学', '几何'],
|
||||
status: 'approved',
|
||||
isPremium: true,
|
||||
requiredPoints: 0,
|
||||
price: 24.9
|
||||
}
|
||||
]
|
||||
|
||||
async function initKnowledgeData() {
|
||||
try {
|
||||
// 连接数据库
|
||||
await mongoose.connect(process.env.MONGODB_URI, {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true
|
||||
})
|
||||
|
||||
console.log('✅ 数据库连接成功')
|
||||
|
||||
// 清空现有知识数据(可选)
|
||||
await Knowledge.deleteMany({})
|
||||
console.log('✅ 已清空现有知识数据')
|
||||
|
||||
// 插入测试数据
|
||||
const inserted = await Knowledge.insertMany(testKnowledgeData)
|
||||
console.log(`✅ 成功插入 ${inserted.length} 条知识数据`)
|
||||
|
||||
// 显示插入的数据统计
|
||||
const dimCounts = {}
|
||||
inserted.forEach(item => {
|
||||
dimCounts[item.dim] = (dimCounts[item.dim] || 0) + 1
|
||||
})
|
||||
|
||||
console.log('📊 知识数据统计:')
|
||||
Object.keys(dimCounts).sort().forEach(dim => {
|
||||
console.log(` ${dim}维知识: ${dimCounts[dim]} 条`)
|
||||
})
|
||||
|
||||
const premiumCount = inserted.filter(item => item.isPremium).length
|
||||
const freeCount = inserted.length - premiumCount
|
||||
console.log(` 💰 付费内容: ${premiumCount} 条`)
|
||||
console.log(` 🆓 免费内容: ${freeCount} 条`)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化知识数据失败:', error)
|
||||
} finally {
|
||||
await mongoose.connection.close()
|
||||
console.log('🔚 数据库连接已关闭')
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
if (require.main === module) {
|
||||
initKnowledgeData()
|
||||
}
|
||||
|
||||
module.exports = { initKnowledgeData }
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 拼音探索模块初始化数据脚本
|
||||
* 创建拼音内容、成就等基础数据
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: './.env' });
|
||||
const mongoose = require('mongoose');
|
||||
const { PinyinContent, PinyinAchievement } = require('../src/models/pinyin');
|
||||
const connectDB = require('../src/config/database');
|
||||
|
||||
// 声母数据
|
||||
const initials = [
|
||||
{ symbol: 'b', name: '玻', order: 1 },
|
||||
{ symbol: 'p', name: '坡', order: 2 },
|
||||
{ symbol: 'm', name: '摸', order: 3 },
|
||||
{ symbol: 'f', name: '佛', order: 4 },
|
||||
{ symbol: 'd', name: '得', order: 5 },
|
||||
{ symbol: 't', name: '特', order: 6 },
|
||||
{ symbol: 'n', name: '讷', order: 7 },
|
||||
{ symbol: 'l', name: '勒', order: 8 },
|
||||
{ symbol: 'g', name: '哥', order: 9 },
|
||||
{ symbol: 'k', name: '科', order: 10 },
|
||||
{ symbol: 'h', name: '喝', order: 11 },
|
||||
{ symbol: 'j', name: '基', order: 12 },
|
||||
{ symbol: 'q', name: '期', order: 13 },
|
||||
{ symbol: 'x', name: '希', order: 14 },
|
||||
{ symbol: 'zh', name: '知', order: 15 },
|
||||
{ symbol: 'ch', name: '蚩', order: 16 },
|
||||
{ symbol: 'sh', name: '诗', order: 17 },
|
||||
{ symbol: 'r', name: '日', order: 18 },
|
||||
{ symbol: 'z', name: '资', order: 19 },
|
||||
{ symbol: 'c', name: '雌', order: 20 },
|
||||
{ symbol: 's', name: '思', order: 21 },
|
||||
{ symbol: 'y', name: '医', order: 22 },
|
||||
{ symbol: 'w', name: '巫', order: 23 }
|
||||
];
|
||||
|
||||
// 韵母数据
|
||||
const finals = [
|
||||
// 单韵母
|
||||
{ symbol: 'a', name: '啊', order: 1 },
|
||||
{ symbol: 'o', name: '喔', order: 2 },
|
||||
{ symbol: 'e', name: '鹅', order: 3 },
|
||||
{ symbol: 'i', name: '衣', order: 4 },
|
||||
{ symbol: 'u', name: '乌', order: 5 },
|
||||
{ symbol: 'ü', name: '迂', order: 6 },
|
||||
// 复韵母
|
||||
{ symbol: 'ai', name: '哀', order: 7 },
|
||||
{ symbol: 'ei', name: '诶', order: 8 },
|
||||
{ symbol: 'ui', name: '威', order: 9 },
|
||||
{ symbol: 'ao', name: '熬', order: 10 },
|
||||
{ symbol: 'ou', name: '欧', order: 11 },
|
||||
{ symbol: 'iu', name: '优', order: 12 },
|
||||
{ symbol: 'ie', name: '耶', order: 13 },
|
||||
{ symbol: 'üe', name: '约', order: 14 },
|
||||
{ symbol: 'er', name: '儿', order: 15 },
|
||||
// 前鼻韵母
|
||||
{ symbol: 'an', name: '安', order: 16 },
|
||||
{ symbol: 'en', name: '恩', order: 17 },
|
||||
{ symbol: 'in', name: '因', order: 18 },
|
||||
{ symbol: 'un', name: '温', order: 19 },
|
||||
{ symbol: 'ün', name: '晕', order: 20 },
|
||||
// 后鼻韵母
|
||||
{ symbol: 'ang', name: '昂', order: 21 },
|
||||
{ symbol: 'eng', name: '亨', order: 22 },
|
||||
{ symbol: 'ing', name: '英', order: 23 },
|
||||
{ symbol: 'ong', name: '雍', order: 24 }
|
||||
];
|
||||
|
||||
// 整体认读音节
|
||||
const overalls = [
|
||||
{ symbol: 'zhi', name: '织', order: 1 },
|
||||
{ symbol: 'chi', name: '吃', order: 2 },
|
||||
{ symbol: 'shi', name: '诗', order: 3 },
|
||||
{ symbol: 'ri', name: '日', order: 4 },
|
||||
{ symbol: 'zi', name: '资', order: 5 },
|
||||
{ symbol: 'ci', name: '雌', order: 6 },
|
||||
{ symbol: 'si', name: '思', order: 7 },
|
||||
{ symbol: 'yi', name: '衣', order: 8 },
|
||||
{ symbol: 'wu', name: '乌', order: 9 },
|
||||
{ symbol: 'yu', name: '迂', order: 10 },
|
||||
{ symbol: 'ye', name: '耶', order: 11 },
|
||||
{ symbol: 'yue', name: '约', order: 12 },
|
||||
{ symbol: 'yuan', name: '冤', order: 13 },
|
||||
{ symbol: 'yin', name: '因', order: 14 },
|
||||
{ symbol: 'yun', name: '晕', order: 15 },
|
||||
{ symbol: 'ying', name: '英', order: 16 }
|
||||
];
|
||||
|
||||
// 成就数据
|
||||
const achievements = [
|
||||
// 探索类成就
|
||||
{ code: 'first_explore', name: '初次探索', description: '完成首次拼音探索', type: 'explore', condition: { type: 'explore_count', value: 1 } },
|
||||
{ code: 'explorer_5', name: '初级探索者', description: '探索5个拼音', type: 'explore', condition: { type: 'explore_count', value: 5 } },
|
||||
{ code: 'explorer_10', name: '中级探索者', description: '探索10个拼音', type: 'explore', condition: { type: 'explore_count', value: 10 } },
|
||||
{ code: 'explorer_23', name: '声母专家', description: '探索所有声母', type: 'explore', condition: { type: 'explore_count', value: 23 } },
|
||||
{ code: 'explorer_47', name: '拼音达人', description: '探索47个拼音', type: 'explore', condition: { type: 'explore_count', value: 47 } },
|
||||
{ code: 'explorer_all', name: '拼音大师', description: '探索所有拼音', type: 'explore', condition: { type: 'explore_count', value: 63 } },
|
||||
|
||||
// 收集类成就
|
||||
{ code: 'first_stone', name: '第一颗能量石', description: '收集第一颗能量石', type: 'collection', condition: { type: 'collect_count', value: 1 } },
|
||||
{ code: 'collector_5', name: '能量收集者', description: '收集5颗能量石', type: 'collection', condition: { type: 'collect_count', value: 5 } },
|
||||
{ code: 'collector_10', name: '能量守护者', description: '收集10颗能量石', type: 'collection', condition: { type: 'collect_count', value: 10 } },
|
||||
{ code: 'collector_23', name: '声母守护者', description: '收集所有声母能量石', type: 'collection', condition: { type: 'collect_count', value: 23 } },
|
||||
{ code: 'collector_all', name: '能量大师', description: '收集所有能量石', type: 'collection', condition: { type: 'collect_count', value: 63 } },
|
||||
|
||||
// 连续探索成就
|
||||
{ code: 'streak_3', name: '坚持3天', description: '连续探索3天', type: 'streak', condition: { type: 'streak_days', value: 3 } },
|
||||
{ code: 'streak_7', name: '坚持一周', description: '连续探索7天', type: 'streak', condition: { type: 'streak_days', value: 7 } },
|
||||
{ code: 'streak_30', name: '坚持一个月', description: '连续探索30天', type: 'streak', condition: { type: 'streak_days', value: 30 } }
|
||||
];
|
||||
|
||||
async function initPinyinContents() {
|
||||
console.log('开始初始化拼音内容...');
|
||||
|
||||
// 初始化声母
|
||||
for (const item of initials) {
|
||||
await PinyinContent.findOneAndUpdate(
|
||||
{ symbol: item.symbol },
|
||||
{
|
||||
symbol: item.symbol,
|
||||
type: 'initial',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
isFree: item.order <= 5, // 前5个免费
|
||||
status: 'active',
|
||||
pronunciation: `${item.name}的音`,
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
console.log(`✅ 已初始化 ${initials.length} 个声母`);
|
||||
|
||||
// 初始化韵母
|
||||
for (const item of finals) {
|
||||
await PinyinContent.findOneAndUpdate(
|
||||
{ symbol: item.symbol },
|
||||
{
|
||||
symbol: item.symbol,
|
||||
type: 'final',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
isFree: false, // 韵母需要解锁
|
||||
status: 'active',
|
||||
pronunciation: `${item.name}的音`,
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
console.log(`✅ 已初始化 ${finals.length} 个韵母`);
|
||||
|
||||
// 初始化整体认读音节
|
||||
for (const item of overalls) {
|
||||
await PinyinContent.findOneAndUpdate(
|
||||
{ symbol: item.symbol },
|
||||
{
|
||||
symbol: item.symbol,
|
||||
type: 'overall',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
isFree: false, // 整体认读需要解锁
|
||||
status: 'active',
|
||||
pronunciation: `${item.name}的音`,
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
console.log(`✅ 已初始化 ${overalls.length} 个整体认读音节`);
|
||||
}
|
||||
|
||||
async function initAchievements() {
|
||||
console.log('开始初始化成就...');
|
||||
|
||||
for (let i = 0; i < achievements.length; i++) {
|
||||
const item = achievements[i];
|
||||
await PinyinAchievement.findOneAndUpdate(
|
||||
{ code: item.code },
|
||||
{
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
type: item.type,
|
||||
condition: item.condition,
|
||||
order: i + 1,
|
||||
isActive: true,
|
||||
reward: {
|
||||
type: 'stone',
|
||||
value: 1
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
console.log(`✅ 已初始化 ${achievements.length} 个成就`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// 连接数据库
|
||||
await connectDB();
|
||||
console.log('数据库连接成功');
|
||||
|
||||
// 初始化拼音内容
|
||||
await initPinyinContents();
|
||||
|
||||
// 初始化成就
|
||||
await initAchievements();
|
||||
|
||||
console.log('\n✨ 数据初始化完成!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('初始化失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,42 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
/**
|
||||
* 数据库连接配置
|
||||
*/
|
||||
const connectDB = async () => {
|
||||
try {
|
||||
const mongoURI = process.env.MONGODB_URI ||
|
||||
`mongodb://${process.env.MONGODB_USER}:${process.env.MONGODB_PASSWORD}@${process.env.MONGODB_HOST}:${process.env.MONGODB_PORT}/${process.env.MONGODB_DB}?authSource=${process.env.MONGODB_DB}`
|
||||
|
||||
const conn = await mongoose.connect(mongoURI, {
|
||||
// Mongoose 6+ 不再需要这些选项,但保留以兼容旧版本
|
||||
// useNewUrlParser: true,
|
||||
// useUnifiedTopology: true,
|
||||
})
|
||||
|
||||
console.log(`✅ MongoDB 连接成功: ${conn.connection.host}:${conn.connection.port}/${conn.connection.name}`)
|
||||
|
||||
// 连接事件监听
|
||||
mongoose.connection.on('error', (err) => {
|
||||
console.error('❌ MongoDB 连接错误:', err)
|
||||
})
|
||||
|
||||
mongoose.connection.on('disconnected', () => {
|
||||
console.warn('⚠️ MongoDB 连接断开')
|
||||
})
|
||||
|
||||
// 优雅关闭
|
||||
process.on('SIGINT', async () => {
|
||||
await mongoose.connection.close()
|
||||
console.log('🔌 MongoDB 连接已关闭')
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
return conn
|
||||
} catch (error) {
|
||||
console.error('❌ MongoDB 连接失败:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = connectDB
|
||||
@@ -0,0 +1,288 @@
|
||||
const { PinyinAchievement, PinyinProgress } = require('../../models/pinyin');
|
||||
|
||||
/**
|
||||
* 获取成就列表
|
||||
* GET /api/pinyin/achievements
|
||||
*/
|
||||
exports.getAchievements = async (req, res) => {
|
||||
try {
|
||||
const { type } = req.query;
|
||||
|
||||
const query = { isActive: true };
|
||||
if (type) query.type = type;
|
||||
|
||||
const achievements = await PinyinAchievement.find(query)
|
||||
.sort({ order: 1, createdAt: 1 })
|
||||
.select('-__v');
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
list: achievements
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取成就列表失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取成就列表失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户已获得的成就
|
||||
* GET /api/pinyin/achievements/my
|
||||
*/
|
||||
exports.getMyAchievements = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
|
||||
const progress = await PinyinProgress.findOne({ userId });
|
||||
|
||||
if (!progress || !progress.achievements || progress.achievements.length === 0) {
|
||||
return res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
total: 0,
|
||||
list: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取成就详情
|
||||
const achievementCodes = progress.achievements.map(a => a.code);
|
||||
const achievements = await PinyinAchievement.find({
|
||||
code: { $in: achievementCodes }
|
||||
}).select('-__v');
|
||||
|
||||
// 合并获得时间
|
||||
const achievementsWithTime = achievements.map(ach => {
|
||||
const userAch = progress.achievements.find(a => a.code === ach.code);
|
||||
return {
|
||||
...ach.toObject(),
|
||||
obtainedAt: userAch ? userAch.obtainedAt : null
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
total: achievementsWithTime.length,
|
||||
list: achievementsWithTime
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取用户成就失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取用户成就失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查成就达成
|
||||
* POST /api/pinyin/achievements/check
|
||||
*/
|
||||
exports.checkAchievements = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
|
||||
const progress = await PinyinProgress.findOne({ userId });
|
||||
if (!progress) {
|
||||
return res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
newAchievements: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有启用的成就
|
||||
const achievements = await PinyinAchievement.find({ isActive: true });
|
||||
|
||||
const newAchievements = [];
|
||||
|
||||
for (const achievement of achievements) {
|
||||
// 检查是否已获得
|
||||
if (progress.achievements.some(a => a.code === achievement.code)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查条件
|
||||
const isAchieved = checkCondition(progress, achievement.condition);
|
||||
|
||||
if (isAchieved) {
|
||||
progress.achievements.push({
|
||||
code: achievement.code,
|
||||
obtainedAt: new Date()
|
||||
});
|
||||
|
||||
newAchievements.push({
|
||||
code: achievement.code,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
reward: achievement.reward
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newAchievements.length > 0) {
|
||||
await progress.save();
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
newAchievements
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('检查成就失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '检查成就失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建成就(管理员)
|
||||
* POST /api/pinyin/achievements
|
||||
*/
|
||||
exports.createAchievement = async (req, res) => {
|
||||
try {
|
||||
const achievementData = req.body;
|
||||
|
||||
// 检查code是否已存在
|
||||
const existing = await PinyinAchievement.findOne({ code: achievementData.code });
|
||||
if (existing) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '该成就代码已存在'
|
||||
});
|
||||
}
|
||||
|
||||
const achievement = new PinyinAchievement(achievementData);
|
||||
await achievement.save();
|
||||
|
||||
res.status(201).json({
|
||||
code: 0,
|
||||
message: '创建成功',
|
||||
data: achievement
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建成就失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '创建成就失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新成就(管理员)
|
||||
* PUT /api/pinyin/achievements/:id
|
||||
*/
|
||||
exports.updateAchievement = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
// 不允许修改code
|
||||
delete updateData.code;
|
||||
|
||||
const achievement = await PinyinAchievement.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: updateData },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!achievement) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '成就不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '更新成功',
|
||||
data: achievement
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新成就失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '更新成就失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除成就(管理员)
|
||||
* DELETE /api/pinyin/achievements/:id
|
||||
*/
|
||||
exports.deleteAchievement = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const achievement = await PinyinAchievement.findByIdAndDelete(id);
|
||||
|
||||
if (!achievement) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '成就不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除成就失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '删除成就失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查成就条件
|
||||
* @param {Object} progress - 用户进度
|
||||
* @param {Object} condition - 条件
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function checkCondition(progress, condition) {
|
||||
switch (condition.type) {
|
||||
case 'explore_count':
|
||||
return progress.totalExplored >= condition.value;
|
||||
|
||||
case 'collect_count':
|
||||
return progress.totalStones >= condition.value;
|
||||
|
||||
case 'streak_days':
|
||||
return progress.streakDays >= condition.value;
|
||||
|
||||
case 'complete_symbol':
|
||||
return progress.isSymbolCompleted(condition.symbol);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
const { PinyinContent } = require('../../models/pinyin');
|
||||
|
||||
/**
|
||||
* 获取拼音内容列表
|
||||
* GET /api/pinyin/contents
|
||||
*/
|
||||
exports.getContents = async (req, res) => {
|
||||
try {
|
||||
const { type, isFree, page = 1, limit = 20 } = req.query;
|
||||
|
||||
// 构建查询条件
|
||||
const query = { status: 'active' };
|
||||
if (type) query.type = type;
|
||||
if (isFree !== undefined) query.isFree = isFree === 'true';
|
||||
|
||||
// 分页
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
// 查询数据
|
||||
const contents = await PinyinContent.find(query)
|
||||
.select('-__v')
|
||||
.sort({ order: 1, symbol: 1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit));
|
||||
|
||||
// 获取总数
|
||||
const total = await PinyinContent.countDocuments(query);
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
list: contents,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取拼音内容列表失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取拼音内容列表失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个拼音详情
|
||||
* GET /api/pinyin/contents/:symbol
|
||||
*/
|
||||
exports.getContentBySymbol = async (req, res) => {
|
||||
try {
|
||||
const { symbol } = req.params;
|
||||
|
||||
const content = await PinyinContent.findOne({
|
||||
symbol: symbol.toLowerCase(),
|
||||
status: 'active'
|
||||
}).select('-__v');
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: content
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取拼音详情失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取拼音详情失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取拼音音频
|
||||
* GET /api/pinyin/contents/:symbol/audio
|
||||
*/
|
||||
exports.getContentAudio = async (req, res) => {
|
||||
try {
|
||||
const { symbol } = req.params;
|
||||
|
||||
const content = await PinyinContent.findOne({
|
||||
symbol: symbol.toLowerCase(),
|
||||
status: 'active'
|
||||
}).select('symbol audioUrl name');
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
symbol: content.symbol,
|
||||
audioUrl: content.audioUrl,
|
||||
name: content.name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取拼音音频失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取拼音音频失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建拼音内容(管理员)
|
||||
* POST /api/pinyin/contents
|
||||
*/
|
||||
exports.createContent = async (req, res) => {
|
||||
try {
|
||||
const contentData = req.body;
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = await PinyinContent.findOne({ symbol: contentData.symbol });
|
||||
if (existing) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '该拼音内容已存在'
|
||||
});
|
||||
}
|
||||
|
||||
const content = new PinyinContent(contentData);
|
||||
await content.save();
|
||||
|
||||
res.status(201).json({
|
||||
code: 0,
|
||||
message: '创建成功',
|
||||
data: content
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建拼音内容失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '创建拼音内容失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新拼音内容(管理员)
|
||||
* PUT /api/pinyin/contents/:id
|
||||
*/
|
||||
exports.updateContent = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
// 不允许修改symbol
|
||||
delete updateData.symbol;
|
||||
|
||||
const content = await PinyinContent.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: updateData },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '更新成功',
|
||||
data: content
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新拼音内容失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '更新拼音内容失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除拼音内容(管理员)
|
||||
* DELETE /api/pinyin/contents/:id
|
||||
*/
|
||||
exports.deleteContent = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const content = await PinyinContent.findByIdAndDelete(id);
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除拼音内容失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '删除拼音内容失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传拼音音频
|
||||
* POST /api/pinyin/contents/:symbol/audio
|
||||
*/
|
||||
exports.uploadAudio = async (req, res) => {
|
||||
try {
|
||||
const { symbol } = req.params;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '请上传音频文件'
|
||||
});
|
||||
}
|
||||
|
||||
// 构建文件URL(假设使用静态文件服务)
|
||||
const audioUrl = `/uploads/audio/${req.file.filename}`;
|
||||
|
||||
// 更新拼音内容的音频URL
|
||||
const content = await PinyinContent.findOneAndUpdate(
|
||||
{ symbol: symbol.toLowerCase() },
|
||||
{ $set: { audioUrl } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '音频上传成功',
|
||||
data: { audioUrl }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('上传音频失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '上传音频失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传口型图
|
||||
* POST /api/pinyin/contents/:symbol/mouth-image
|
||||
*/
|
||||
exports.uploadMouthImage = async (req, res) => {
|
||||
try {
|
||||
const { symbol } = req.params;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '请上传图片文件'
|
||||
});
|
||||
}
|
||||
|
||||
// 构建文件URL
|
||||
const mouthImage = `/uploads/images/${req.file.filename}`;
|
||||
|
||||
// 更新拼音内容的口型图URL
|
||||
const content = await PinyinContent.findOneAndUpdate(
|
||||
{ symbol: symbol.toLowerCase() },
|
||||
{ $set: { mouthImage } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '口型图上传成功',
|
||||
data: { mouthImage }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('上传口型图失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '上传口型图失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量更新音频URL(从外部TTS服务)
|
||||
* POST /api/pinyin/contents/batch-update-audio
|
||||
*/
|
||||
exports.batchUpdateAudio = async (req, res) => {
|
||||
try {
|
||||
const { audioBaseUrl } = req.body;
|
||||
|
||||
if (!audioBaseUrl) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '请提供音频基础URL'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有拼音内容
|
||||
const contents = await PinyinContent.find({});
|
||||
|
||||
// 批量更新音频URL
|
||||
const updatePromises = contents.map(content => {
|
||||
const audioUrl = `${audioBaseUrl}/${content.symbol}.mp3`;
|
||||
return PinyinContent.findByIdAndUpdate(content._id, { $set: { audioUrl } });
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: `成功更新 ${contents.length} 个拼音的音频URL`,
|
||||
data: { updatedCount: contents.length }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('批量更新音频失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '批量更新音频失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,320 @@
|
||||
const { PinyinGameRecord, PinyinProgress, PinyinContent } = require('../../models/pinyin');
|
||||
|
||||
/**
|
||||
* 游戏配置
|
||||
*/
|
||||
const GAME_CONFIG = {
|
||||
match: {
|
||||
name: '拼音配对',
|
||||
description: '找出相同的拼音卡片',
|
||||
difficulty: {
|
||||
easy: { pairs: 4, time: 60 },
|
||||
normal: { pairs: 6, time: 90 },
|
||||
hard: { pairs: 8, time: 120 }
|
||||
}
|
||||
},
|
||||
tone: {
|
||||
name: '声调过山车',
|
||||
description: '根据声调高低控制轨道',
|
||||
difficulty: {
|
||||
easy: { questions: 5, time: 60 },
|
||||
normal: { questions: 8, time: 90 },
|
||||
hard: { questions: 10, time: 120 }
|
||||
}
|
||||
},
|
||||
find: {
|
||||
name: '找拼音',
|
||||
description: '在场景中找到指定拼音',
|
||||
difficulty: {
|
||||
easy: { targets: 3, distractors: 6, time: 60 },
|
||||
normal: { targets: 5, distractors: 10, time: 90 },
|
||||
hard: { targets: 7, distractors: 14, time: 120 }
|
||||
}
|
||||
},
|
||||
puzzle: {
|
||||
name: '拼音拼图',
|
||||
description: '拖拽拼音组成完整音节',
|
||||
difficulty: {
|
||||
easy: { pieces: 2, time: 60 },
|
||||
normal: { pieces: 3, time: 90 },
|
||||
hard: { pieces: 4, time: 120 }
|
||||
}
|
||||
},
|
||||
mimic: {
|
||||
name: '语音模仿',
|
||||
description: '模仿发音,获得反馈',
|
||||
difficulty: {
|
||||
easy: { targets: 3, time: 60 },
|
||||
normal: { targets: 5, time: 90 },
|
||||
hard: { targets: 7, time: 120 }
|
||||
}
|
||||
},
|
||||
runner: {
|
||||
name: '拼音跑酷',
|
||||
description: '躲避障碍,收集正确拼音',
|
||||
difficulty: {
|
||||
easy: { distance: 100, obstacles: 5, time: 60 },
|
||||
normal: { distance: 200, obstacles: 10, time: 90 },
|
||||
hard: { distance: 300, obstacles: 15, time: 120 }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取游戏配置
|
||||
* GET /api/pinyin/games/config
|
||||
*/
|
||||
exports.getGameConfig = async (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
games: GAME_CONFIG
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取游戏配置失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取游戏配置失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指定游戏配置
|
||||
* GET /api/pinyin/games/config/:type
|
||||
*/
|
||||
exports.getGameConfigByType = async (req, res) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
|
||||
if (!GAME_CONFIG[type]) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '游戏类型不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
type,
|
||||
config: GAME_CONFIG[type]
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取游戏配置失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取游戏配置失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 记录游戏结果
|
||||
* POST /api/pinyin/games/record
|
||||
*/
|
||||
exports.recordGame = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
const {
|
||||
gameType,
|
||||
difficulty = 'normal',
|
||||
score,
|
||||
duration,
|
||||
correctCount,
|
||||
wrongCount,
|
||||
details = {}
|
||||
} = req.body;
|
||||
|
||||
// 验证参数
|
||||
if (!gameType || !GAME_CONFIG[gameType]) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '无效的游戏类型'
|
||||
});
|
||||
}
|
||||
|
||||
if (score === undefined || duration === undefined) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '缺少必要参数:score 或 duration'
|
||||
});
|
||||
}
|
||||
|
||||
// 计算准确率
|
||||
const total = (correctCount || 0) + (wrongCount || 0);
|
||||
const accuracy = total > 0 ? Math.round((correctCount / total) * 100) : 0;
|
||||
|
||||
// 创建游戏记录
|
||||
const record = new PinyinGameRecord({
|
||||
userId,
|
||||
gameType,
|
||||
difficulty,
|
||||
score,
|
||||
duration,
|
||||
correctCount: correctCount || 0,
|
||||
wrongCount: wrongCount || 0,
|
||||
accuracy,
|
||||
details,
|
||||
playedAt: new Date()
|
||||
});
|
||||
|
||||
await record.save();
|
||||
|
||||
// 更新用户进度中的游戏统计
|
||||
let progress = await PinyinProgress.findOne({ userId });
|
||||
if (!progress) {
|
||||
progress = new PinyinProgress({ userId });
|
||||
}
|
||||
progress.updateDailyStats('game', duration);
|
||||
await progress.save();
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
record: {
|
||||
id: record._id,
|
||||
gameType,
|
||||
score,
|
||||
accuracy,
|
||||
playedAt: record.playedAt
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('记录游戏结果失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '记录游戏结果失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户游戏记录
|
||||
* GET /api/pinyin/games/records
|
||||
*/
|
||||
exports.getGameRecords = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
const { gameType, page = 1, limit = 20 } = req.query;
|
||||
|
||||
const query = { userId };
|
||||
if (gameType) query.gameType = gameType;
|
||||
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const records = await PinyinGameRecord.find(query)
|
||||
.sort({ playedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.select('-__v');
|
||||
|
||||
const total = await PinyinGameRecord.countDocuments(query);
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
list: records,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取游戏记录失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取游戏记录失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户游戏统计
|
||||
* GET /api/pinyin/games/stats
|
||||
*/
|
||||
exports.getGameStats = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
|
||||
const stats = await PinyinGameRecord.getUserStats(userId);
|
||||
|
||||
// 格式化统计数据
|
||||
const formattedStats = {};
|
||||
Object.keys(GAME_CONFIG).forEach(type => {
|
||||
const stat = stats.find(s => s._id === type);
|
||||
formattedStats[type] = {
|
||||
name: GAME_CONFIG[type].name,
|
||||
totalGames: stat ? stat.totalGames : 0,
|
||||
totalScore: stat ? stat.totalScore : 0,
|
||||
avgScore: stat ? Math.round(stat.avgScore) : 0,
|
||||
maxScore: stat ? stat.maxScore : 0,
|
||||
avgAccuracy: stat ? Math.round(stat.avgAccuracy) : 0
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
stats: formattedStats
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取游戏统计失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取游戏统计失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取游戏排行榜
|
||||
* GET /api/pinyin/games/leaderboard/:type
|
||||
*/
|
||||
exports.getLeaderboard = async (req, res) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
const { limit = 10 } = req.query;
|
||||
|
||||
if (!GAME_CONFIG[type]) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '游戏类型不存在'
|
||||
});
|
||||
}
|
||||
|
||||
const leaderboard = await PinyinGameRecord.getLeaderboard(type, parseInt(limit));
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
gameType: type,
|
||||
gameName: GAME_CONFIG[type].name,
|
||||
leaderboard
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取游戏排行榜失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取游戏排行榜失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
const { PinyinProgress, PinyinContent, PinyinAchievement } = require('../../models/pinyin');
|
||||
|
||||
/**
|
||||
* 获取用户探索进度
|
||||
* GET /api/pinyin/progress
|
||||
*/
|
||||
exports.getProgress = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
|
||||
let progress = await PinyinProgress.findOne({ userId });
|
||||
|
||||
// 如果没有进度记录,创建新记录
|
||||
if (!progress) {
|
||||
progress = new PinyinProgress({ userId });
|
||||
await progress.save();
|
||||
}
|
||||
|
||||
// 获取所有拼音内容数量
|
||||
const totalSymbols = await PinyinContent.countDocuments({ status: 'active' });
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
progress: {
|
||||
totalExplored: progress.totalExplored,
|
||||
totalStones: progress.totalStones,
|
||||
totalSymbols: totalSymbols,
|
||||
exploredSymbols: progress.exploredSymbols,
|
||||
currentSymbol: progress.currentSymbol,
|
||||
achievements: progress.achievements,
|
||||
streakDays: progress.streakDays
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取探索进度失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取探索进度失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 记录探索行为
|
||||
* POST /api/pinyin/progress/explore
|
||||
*/
|
||||
exports.recordExplore = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
const { symbol, area, duration = 0 } = req.body;
|
||||
|
||||
if (!symbol || !area) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '缺少必要参数:symbol 或 area'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证拼音是否存在
|
||||
const content = await PinyinContent.findOne({
|
||||
symbol: symbol.toLowerCase(),
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取或创建进度记录
|
||||
let progress = await PinyinProgress.findOne({ userId });
|
||||
if (!progress) {
|
||||
progress = new PinyinProgress({ userId });
|
||||
}
|
||||
|
||||
// 检查是否首次探索该拼音
|
||||
const isFirstExplore = !progress.hasExplored(symbol);
|
||||
|
||||
// 添加探索记录
|
||||
progress.addExplore(symbol, area);
|
||||
|
||||
// 更新每日统计
|
||||
progress.updateDailyStats('explore', duration);
|
||||
|
||||
// 检查是否完成所有区域
|
||||
const isCompleted = progress.isSymbolCompleted(symbol);
|
||||
|
||||
// 检查成就
|
||||
const newAchievements = await checkAchievements(progress);
|
||||
|
||||
await progress.save();
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
isFirstExplore,
|
||||
isCompleted,
|
||||
newAchievements,
|
||||
progress: {
|
||||
totalExplored: progress.totalExplored,
|
||||
totalStones: progress.totalStones,
|
||||
currentSymbol: progress.currentSymbol
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('记录探索行为失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '记录探索行为失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 收集能量石
|
||||
* POST /api/pinyin/progress/collect
|
||||
*/
|
||||
exports.collectStone = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
const { symbol } = req.body;
|
||||
|
||||
if (!symbol) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '缺少必要参数:symbol'
|
||||
});
|
||||
}
|
||||
|
||||
let progress = await PinyinProgress.findOne({ userId });
|
||||
if (!progress) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '探索进度不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否已完成该拼音的所有区域
|
||||
if (!progress.isSymbolCompleted(symbol)) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '请先完成该拼音的所有探索区域'
|
||||
});
|
||||
}
|
||||
|
||||
// 收集能量石
|
||||
const isCollected = progress.collectStone(symbol);
|
||||
|
||||
if (!isCollected) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '该能量石已收集'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查成就
|
||||
const newAchievements = await checkAchievements(progress);
|
||||
|
||||
await progress.save();
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
isCollected: true,
|
||||
totalStones: progress.totalStones,
|
||||
newAchievements
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('收集能量石失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '收集能量石失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户探索统计
|
||||
* GET /api/pinyin/progress/stats
|
||||
*/
|
||||
exports.getStats = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
|
||||
const progress = await PinyinProgress.findOne({ userId });
|
||||
|
||||
if (!progress) {
|
||||
return res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
totalExplored: 0,
|
||||
totalStones: 0,
|
||||
streakDays: 0,
|
||||
achievements: [],
|
||||
dailyStats: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取最近7天的统计
|
||||
const last7Days = progress.dailyStats
|
||||
.sort((a, b) => b.date - a.date)
|
||||
.slice(0, 7);
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
totalExplored: progress.totalExplored,
|
||||
totalStones: progress.totalStones,
|
||||
streakDays: progress.streakDays,
|
||||
achievements: progress.achievements,
|
||||
dailyStats: last7Days
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取探索统计失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取探索统计失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取探索排行榜
|
||||
* GET /api/pinyin/progress/leaderboard
|
||||
*/
|
||||
exports.getLeaderboard = async (req, res) => {
|
||||
try {
|
||||
const { type = 'stones', limit = 10 } = req.query;
|
||||
|
||||
let sortField = 'totalStones';
|
||||
if (type === 'explored') sortField = 'totalExplored';
|
||||
if (type === 'achievements') sortField = 'achievements';
|
||||
|
||||
const leaderboard = await PinyinProgress.find()
|
||||
.sort({ [sortField]: -1 })
|
||||
.limit(parseInt(limit))
|
||||
.populate('userId', 'nickname avatar')
|
||||
.select('totalStones totalExplored achievements streakDays');
|
||||
|
||||
const formattedLeaderboard = leaderboard.map((item, index) => ({
|
||||
rank: index + 1,
|
||||
userId: item.userId?._id,
|
||||
nickname: item.userId?.nickname || '匿名用户',
|
||||
avatar: item.userId?.avatar || '',
|
||||
totalStones: item.totalStones,
|
||||
totalExplored: item.totalExplored,
|
||||
achievements: item.achievements.length,
|
||||
streakDays: item.streakDays
|
||||
}));
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
type,
|
||||
leaderboard: formattedLeaderboard
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取排行榜失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取排行榜失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查成就达成
|
||||
* @param {Object} progress - 用户进度对象
|
||||
* @returns {Array} - 新获得的成就列表
|
||||
*/
|
||||
async function checkAchievements(progress) {
|
||||
const newAchievements = [];
|
||||
|
||||
// 获取所有启用的成就
|
||||
const achievements = await PinyinAchievement.find({ isActive: true });
|
||||
|
||||
for (const achievement of achievements) {
|
||||
// 检查是否已获得
|
||||
if (progress.achievements.some(a => a.code === achievement.code)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查条件
|
||||
const isAchieved = checkAchievementCondition(progress, achievement.condition);
|
||||
|
||||
if (isAchieved) {
|
||||
progress.addAchievement(achievement.code);
|
||||
newAchievements.push({
|
||||
code: achievement.code,
|
||||
name: achievement.name,
|
||||
icon: achievement.icon
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newAchievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个成就条件
|
||||
* @param {Object} progress - 用户进度
|
||||
* @param {Object} condition - 成就条件
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function checkAchievementCondition(progress, condition) {
|
||||
switch (condition.type) {
|
||||
case 'explore_count':
|
||||
return progress.totalExplored >= condition.value;
|
||||
|
||||
case 'collect_count':
|
||||
return progress.totalStones >= condition.value;
|
||||
|
||||
case 'streak_days':
|
||||
return progress.streakDays >= condition.value;
|
||||
|
||||
case 'complete_symbol':
|
||||
return progress.isSymbolCompleted(condition.symbol);
|
||||
|
||||
case 'complete_type':
|
||||
// 需要查询该类型的所有拼音是否都已完成
|
||||
// 这里简化处理,实际应该查询数据库
|
||||
return false;
|
||||
|
||||
case 'explore_all':
|
||||
// 需要查询所有拼音数量
|
||||
return false;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
require('dotenv').config()
|
||||
|
||||
const express = require('express')
|
||||
const cors = require('cors')
|
||||
const helmet = require('helmet')
|
||||
const compression = require('compression')
|
||||
const morgan = require('morgan')
|
||||
const rateLimit = require('express-rate-limit')
|
||||
|
||||
const connectDB = require('./config/database')
|
||||
const initRoutes = require('./routes')
|
||||
const logger = require('./utils/logger')
|
||||
|
||||
// 创建 Express 应用
|
||||
const app = express()
|
||||
|
||||
// 数据库连接
|
||||
connectDB()
|
||||
|
||||
// 中间件配置
|
||||
app.use(helmet()) // 安全头部
|
||||
app.use(cors()) // 跨域支持
|
||||
app.use(compression()) // Gzip 压缩
|
||||
app.use(express.json({ limit: '10mb' })) // JSON 解析
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' })) // URL 编码解析
|
||||
|
||||
// 日志中间件
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
app.use(morgan('dev'))
|
||||
} else {
|
||||
app.use(morgan('combined'))
|
||||
}
|
||||
|
||||
// 速率限制
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 分钟
|
||||
max: 1000, // 每个 IP 最多 1000 次请求
|
||||
message: {
|
||||
success: false,
|
||||
error: '请求过于频繁,请稍后再试'
|
||||
}
|
||||
})
|
||||
app.use('/api/', limiter)
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
})
|
||||
})
|
||||
|
||||
// API 路由
|
||||
initRoutes(app)
|
||||
|
||||
// 404 处理
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: '接口不存在',
|
||||
path: req.path
|
||||
})
|
||||
})
|
||||
|
||||
// 全局错误处理
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error('服务器错误:', err)
|
||||
|
||||
res.status(err.status || 500).json({
|
||||
success: false,
|
||||
error: process.env.NODE_ENV === 'production'
|
||||
? '服务器内部错误'
|
||||
: err.message,
|
||||
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
|
||||
})
|
||||
})
|
||||
|
||||
// 启动服务器
|
||||
const PORT = process.env.PORT || 3000
|
||||
const server = app.listen(PORT, () => {
|
||||
logger.info(`🚀 服务器启动成功`)
|
||||
logger.info(`📍 端口: ${PORT}`)
|
||||
logger.info(`🌐 环境: ${process.env.NODE_ENV || 'development'}`)
|
||||
logger.info(`📊 健康检查: http://localhost:${PORT}/health`)
|
||||
})
|
||||
|
||||
// 优雅关闭
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('📴 接收到 SIGTERM 信号,正在关闭服务器...')
|
||||
server.close(() => {
|
||||
logger.info('✅ 服务器已关闭')
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
|
||||
module.exports = app
|
||||
@@ -0,0 +1,167 @@
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { Admin, User } = require('../models')
|
||||
|
||||
/**
|
||||
* JWT 认证中间件(管理员)
|
||||
*/
|
||||
const authMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
// 从 header 获取 token
|
||||
const authHeader = req.header('Authorization')
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未提供认证令牌'
|
||||
})
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
|
||||
// 验证 token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
|
||||
// 查找管理员
|
||||
const admin = await Admin.findById(decoded.adminId)
|
||||
if (!admin) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '管理员不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (admin.status !== 'active') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '管理员账户已禁用'
|
||||
})
|
||||
}
|
||||
|
||||
// 将管理员信息附加到请求对象
|
||||
req.admin = admin
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '无效的认证令牌'
|
||||
})
|
||||
}
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '认证令牌已过期'
|
||||
})
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限检查中间件
|
||||
* @param {string} permission - 需要的权限
|
||||
*/
|
||||
const requirePermission = (permission) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.admin) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未认证'
|
||||
})
|
||||
}
|
||||
|
||||
if (!req.admin.hasPermission(permission)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序用户 JWT 认证中间件
|
||||
*/
|
||||
const userAuthMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.header('Authorization')
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未提供认证令牌'
|
||||
})
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
|
||||
// 添加调试日志
|
||||
console.log('[Auth] Token received:', token.substring(0, 20) + '...')
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
console.log('[Auth] Token decoded:', decoded)
|
||||
|
||||
// 兼容两种token格式:包含userId或openid
|
||||
let user = null
|
||||
if (decoded.userId) {
|
||||
console.log('[Auth] Looking up user by userId:', decoded.userId)
|
||||
user = await User.findById(decoded.userId)
|
||||
} else if (decoded.openid) {
|
||||
console.log('[Auth] Looking up user by openid:', decoded.openid)
|
||||
user = await User.findOne({ openid: decoded.openid })
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.log('[Auth] User not found')
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '用户不存在或令牌无效'
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[Auth] User found:', user._id)
|
||||
console.log('[Auth] User openid:', user.openid)
|
||||
req.user = user
|
||||
// 优先使用 openid,如果没有则使用 userId 作为标识
|
||||
req.openid = user.openid || user._id.toString()
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '认证令牌无效或已过期'
|
||||
})
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选的用户认证中间件(不强制要求登录)
|
||||
*/
|
||||
const optionalAuthMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.header('Authorization')
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
const user = await User.findById(decoded.userId)
|
||||
if (user) {
|
||||
req.user = user
|
||||
req.openid = user.openid
|
||||
}
|
||||
}
|
||||
next()
|
||||
} catch (error) {
|
||||
// 即使认证失败也继续,只是不附加用户信息
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authMiddleware,
|
||||
requirePermission,
|
||||
userAuthMiddleware,
|
||||
optionalAuthMiddleware
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
/**
|
||||
* 用户AI问答次数记录模型
|
||||
*/
|
||||
const AIChatQuotaSchema = new mongoose.Schema({
|
||||
openid: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true
|
||||
},
|
||||
// 剩余免费问答次数
|
||||
freeQuota: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
// 通过分享获得的次数
|
||||
sharedQuota: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 已使用的总次数
|
||||
usedQuota: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 今日已使用次数
|
||||
dailyUsed: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 最后使用日期
|
||||
lastUsedDate: {
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
// 分享获得次数的记录
|
||||
shareRecords: [{
|
||||
sharedAt: { type: Date, default: Date.now },
|
||||
gainedQuota: { type: Number, default: 5 }
|
||||
}],
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
})
|
||||
|
||||
// 更新时自动更新 updatedAt
|
||||
AIChatQuotaSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
next()
|
||||
})
|
||||
|
||||
// 获取总可用次数
|
||||
AIChatQuotaSchema.methods.getTotalQuota = function() {
|
||||
return this.freeQuota + this.sharedQuota
|
||||
}
|
||||
|
||||
// 获取剩余可用次数
|
||||
AIChatQuotaSchema.methods.getRemainingQuota = function() {
|
||||
return this.getTotalQuota() - this.usedQuota
|
||||
}
|
||||
|
||||
// 检查是否有可用次数
|
||||
AIChatQuotaSchema.methods.hasQuota = function() {
|
||||
return this.getRemainingQuota() > 0
|
||||
}
|
||||
|
||||
// 使用一次问答机会
|
||||
AIChatQuotaSchema.methods.useQuota = async function() {
|
||||
if (!this.hasQuota()) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.usedQuota += 1
|
||||
this.dailyUsed += 1
|
||||
this.lastUsedDate = new Date()
|
||||
await this.save()
|
||||
return true
|
||||
}
|
||||
|
||||
// 通过分享增加次数
|
||||
AIChatQuotaSchema.methods.addQuotaByShare = async function(gainedQuota = 5) {
|
||||
this.sharedQuota += gainedQuota
|
||||
this.shareRecords.push({
|
||||
sharedAt: new Date(),
|
||||
gainedQuota
|
||||
})
|
||||
await this.save()
|
||||
return this.getRemainingQuota()
|
||||
}
|
||||
|
||||
// 重置每日使用次数(可定时任务调用)
|
||||
AIChatQuotaSchema.methods.resetDailyUsed = function() {
|
||||
const today = new Date().toDateString()
|
||||
const lastUsed = this.lastUsedDate ? new Date(this.lastUsedDate).toDateString() : null
|
||||
|
||||
if (lastUsed !== today) {
|
||||
this.dailyUsed = 0
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('AIChatQuota', AIChatQuotaSchema)
|
||||
@@ -0,0 +1,117 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
/**
|
||||
* AI模型配置模型
|
||||
*/
|
||||
const AIModelSchema = new mongoose.Schema({
|
||||
// 模型名称
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
// 模型ID
|
||||
modelId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
// API地址
|
||||
apiUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
// API Key
|
||||
apiKey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
// 模型描述
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 是否启用
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 是否为默认模型
|
||||
isDefault: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 模型参数配置
|
||||
config: {
|
||||
temperature: {
|
||||
type: Number,
|
||||
default: 0.7,
|
||||
min: 0,
|
||||
max: 2
|
||||
},
|
||||
maxTokens: {
|
||||
type: Number,
|
||||
default: 800,
|
||||
min: 1,
|
||||
max: 4096
|
||||
},
|
||||
topP: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
min: 0,
|
||||
max: 1
|
||||
}
|
||||
},
|
||||
// 优先级(数字越小优先级越高)
|
||||
priority: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
})
|
||||
|
||||
// 更新时自动更新 updatedAt
|
||||
AIModelSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
next()
|
||||
})
|
||||
|
||||
// 设置默认模型时,取消其他模型的默认状态
|
||||
AIModelSchema.pre('save', async function(next) {
|
||||
if (this.isDefault && this.isModified('isDefault')) {
|
||||
await this.constructor.updateMany(
|
||||
{ _id: { $ne: this._id } },
|
||||
{ isDefault: false }
|
||||
)
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
// 获取当前使用的模型(优先返回默认模型,如果没有则返回第一个启用的模型)
|
||||
AIModelSchema.statics.getCurrentModel = async function() {
|
||||
// 先查找默认模型
|
||||
let model = await this.findOne({ isDefault: true, isActive: true })
|
||||
|
||||
// 如果没有默认模型,返回第一个启用的模型
|
||||
if (!model) {
|
||||
model = await this.findOne({ isActive: true }).sort({ priority: 1, createdAt: -1 })
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
// 获取所有启用的模型列表
|
||||
AIModelSchema.statics.getActiveModels = async function() {
|
||||
return await this.find({ isActive: true }).sort({ priority: 1, createdAt: -1 })
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('AIModel', AIModelSchema)
|
||||
@@ -0,0 +1,130 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
const bcrypt = require('bcryptjs')
|
||||
|
||||
/**
|
||||
* 管理员模型
|
||||
*/
|
||||
const AdminSchema = new Schema({
|
||||
// 登录信息
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
minlength: 3,
|
||||
maxlength: 30
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 6
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
trim: true,
|
||||
lowercase: true
|
||||
},
|
||||
|
||||
// 基本信息
|
||||
realName: String,
|
||||
phone: String,
|
||||
avatar: String,
|
||||
|
||||
// 角色
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['super_admin', 'content_manager', 'shop_manager', 'viewer'],
|
||||
default: 'viewer'
|
||||
},
|
||||
|
||||
// 权限
|
||||
permissions: [{
|
||||
type: String
|
||||
}],
|
||||
|
||||
// 状态
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive', 'suspended'],
|
||||
default: 'active'
|
||||
},
|
||||
|
||||
// 登录信息
|
||||
lastLogin: Date,
|
||||
lastLoginIp: String,
|
||||
loginCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引(username 已通过 unique: true 自动创建索引)
|
||||
AdminSchema.index({ role: 1 })
|
||||
|
||||
// 中间件:保存前加密密码
|
||||
AdminSchema.pre('save', async function(next) {
|
||||
if (!this.isModified('password')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10)
|
||||
this.password = await bcrypt.hash(this.password, salt)
|
||||
next()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 实例方法:验证密码
|
||||
AdminSchema.methods.validatePassword = async function(password) {
|
||||
return bcrypt.compare(password, this.password)
|
||||
}
|
||||
|
||||
// 实例方法:检查权限
|
||||
AdminSchema.methods.hasPermission = function(permission) {
|
||||
if (this.permissions.includes('*')) return true
|
||||
return this.permissions.includes(permission)
|
||||
}
|
||||
|
||||
// 静态方法:根据角色获取权限
|
||||
AdminSchema.statics.getPermissionsByRole = function(role) {
|
||||
const permissionMap = {
|
||||
super_admin: ['*'],
|
||||
content_manager: [
|
||||
'knowledge:read', 'knowledge:write', 'knowledge:delete',
|
||||
'gallery:read', 'gallery:approve', 'gallery:delete',
|
||||
'users:read'
|
||||
],
|
||||
shop_manager: [
|
||||
'shop:read', 'shop:write', 'shop:delete',
|
||||
'orders:read', 'orders:process', 'orders:refund',
|
||||
'users:read'
|
||||
],
|
||||
viewer: [
|
||||
'dashboard:read',
|
||||
'analytics:read',
|
||||
'users:read'
|
||||
]
|
||||
}
|
||||
|
||||
return permissionMap[role] || []
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('Admin', AdminSchema)
|
||||
@@ -0,0 +1,54 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 管理员操作日志模型
|
||||
*/
|
||||
const AdminLogSchema = new Schema({
|
||||
// 管理员信息
|
||||
adminId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'Admin',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
adminName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// 操作信息
|
||||
action: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['login', 'create', 'read', 'update', 'delete', 'review', 'export']
|
||||
},
|
||||
resource: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
resourceId: String,
|
||||
|
||||
// 详细信息
|
||||
details: Schema.Types.Mixed,
|
||||
|
||||
// IP 地址
|
||||
ip: String,
|
||||
userAgent: String,
|
||||
|
||||
// 时间戳
|
||||
timestamp: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
}
|
||||
}, {
|
||||
timestamps: false
|
||||
})
|
||||
|
||||
// 索引
|
||||
AdminLogSchema.index({ adminId: 1, timestamp: -1 })
|
||||
AdminLogSchema.index({ resource: 1, timestamp: -1 })
|
||||
AdminLogSchema.index({ action: 1 })
|
||||
|
||||
module.exports = mongoose.model('AdminLog', AdminLogSchema)
|
||||
@@ -0,0 +1,66 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
const BGMschema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
dimension: {
|
||||
type: Number,
|
||||
required: true,
|
||||
enum: [1, 2, 3, 4, 5],
|
||||
index: true
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
loop: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
volume: {
|
||||
type: Number,
|
||||
default: 0.5,
|
||||
min: 0,
|
||||
max: 1
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
index: true
|
||||
},
|
||||
sortOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
})
|
||||
|
||||
// 更新时自动修改 updatedAt
|
||||
BGMschema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
next()
|
||||
})
|
||||
|
||||
// 复合索引:维度 + 激活状态 + 排序
|
||||
BGMschema.index({ dimension: 1, isActive: 1, sortOrder: 1 })
|
||||
|
||||
module.exports = mongoose.model('BGM', BGMschema)
|
||||
@@ -0,0 +1,191 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 反馈模型
|
||||
* 存储用户的建议、问题和反馈
|
||||
*/
|
||||
const FeedbackSchema = new Schema({
|
||||
// 反馈者信息
|
||||
userId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true
|
||||
},
|
||||
openid: {
|
||||
type: String,
|
||||
index: true
|
||||
},
|
||||
userName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
userContact: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 反馈类型
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['suggestion', 'bug', 'complaint', 'praise', 'other'],
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// 反馈标题
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
maxlength: 100
|
||||
},
|
||||
|
||||
// 反馈内容
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
maxlength: 2000
|
||||
},
|
||||
|
||||
// 图片附件
|
||||
images: [{
|
||||
type: String
|
||||
}],
|
||||
|
||||
// 相关页面/功能
|
||||
relatedPage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 设备信息
|
||||
deviceInfo: {
|
||||
device: { type: String, default: '' },
|
||||
os: { type: String, default: '' },
|
||||
osVersion: { type: String, default: '' },
|
||||
browser: { type: String, default: '' },
|
||||
browserVersion: { type: String, default: '' },
|
||||
screenResolution: { type: String, default: '' },
|
||||
appVersion: { type: String, default: '' }
|
||||
},
|
||||
|
||||
// 网络信息
|
||||
networkInfo: {
|
||||
type: { type: String, default: '' },
|
||||
ip: { type: String, default: '' }
|
||||
},
|
||||
|
||||
// 处理状态
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'processing', 'resolved', 'rejected', 'closed'],
|
||||
default: 'pending',
|
||||
index: true
|
||||
},
|
||||
|
||||
// 优先级
|
||||
priority: {
|
||||
type: String,
|
||||
enum: ['low', 'normal', 'high', 'urgent'],
|
||||
default: 'normal'
|
||||
},
|
||||
|
||||
// 处理记录
|
||||
processLog: [{
|
||||
operator: { type: String, required: true },
|
||||
action: { type: String, required: true },
|
||||
comment: { type: String, default: '' },
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
}],
|
||||
|
||||
// 处理结果
|
||||
result: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 处理人
|
||||
handler: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 处理时间
|
||||
handledAt: {
|
||||
type: Date
|
||||
},
|
||||
|
||||
// 用户评分(对处理结果的满意度)
|
||||
rating: {
|
||||
type: Number,
|
||||
min: 1,
|
||||
max: 5
|
||||
},
|
||||
|
||||
// 用户备注
|
||||
userRemark: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 创建时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
},
|
||||
|
||||
// 更新时间
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: { createdAt: false, updatedAt: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
FeedbackSchema.index({ status: 1, createdAt: -1 })
|
||||
FeedbackSchema.index({ type: 1, createdAt: -1 })
|
||||
FeedbackSchema.index({ priority: 1, createdAt: -1 })
|
||||
|
||||
// 静态方法:获取待处理的反馈数量
|
||||
FeedbackSchema.statics.getPendingCount = function() {
|
||||
return this.countDocuments({ status: 'pending' })
|
||||
}
|
||||
|
||||
// 静态方法:获取反馈统计
|
||||
FeedbackSchema.statics.getStats = function(startDate, endDate) {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdAt: { $gte: startDate, $lte: endDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$status',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// 静态方法:按类型统计
|
||||
FeedbackSchema.statics.getTypeStats = function(startDate, endDate) {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdAt: { $gte: startDate, $lte: endDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$type',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('Feedback', FeedbackSchema)
|
||||
@@ -0,0 +1,106 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 画廊作品模型
|
||||
*/
|
||||
const GallerySchema = new Schema({
|
||||
// 用户信息
|
||||
openid: {
|
||||
type: String,
|
||||
required: false,
|
||||
index: true
|
||||
},
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true
|
||||
},
|
||||
authorName: {
|
||||
type: String,
|
||||
default: '星辰旅者'
|
||||
},
|
||||
authorAvatar: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 作品信息
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 100
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 500
|
||||
},
|
||||
|
||||
// 图片数据(Base64 或 URL)
|
||||
imageData: String,
|
||||
imageUrl: String,
|
||||
|
||||
// 作品属性
|
||||
tags: [{
|
||||
type: String,
|
||||
maxlength: 20
|
||||
}],
|
||||
dim: {
|
||||
type: Number,
|
||||
enum: [1, 2, 3, 4, 5],
|
||||
default: 1
|
||||
},
|
||||
|
||||
// 统计
|
||||
likeCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
viewCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 审核状态
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'approved', 'rejected'],
|
||||
default: 'pending',
|
||||
index: true
|
||||
},
|
||||
reviewComment: String,
|
||||
reviewedAt: Date,
|
||||
reviewerId: String,
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
GallerySchema.index({ openid: 1, createdAt: -1 })
|
||||
GallerySchema.index({ status: 1, createdAt: -1 })
|
||||
GallerySchema.index({ dim: 1, createdAt: -1 })
|
||||
GallerySchema.index({ likeCount: -1 })
|
||||
|
||||
// 静态方法:获取今日作品数
|
||||
GallerySchema.statics.getTodayCount = async function() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
return this.countDocuments({
|
||||
createdAt: { $gte: today },
|
||||
status: 'approved'
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('Gallery', GallerySchema)
|
||||
@@ -0,0 +1,125 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 知识库模型
|
||||
*/
|
||||
const KnowledgeSchema = new Schema({
|
||||
// 基本信息
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 100
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// 分类
|
||||
dim: {
|
||||
type: Number,
|
||||
enum: [1, 2, 3, 4, 5],
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
enum: ['concept', 'history', 'application', 'question'],
|
||||
required: true
|
||||
},
|
||||
tags: [{
|
||||
type: String,
|
||||
maxlength: 20
|
||||
}],
|
||||
|
||||
// 多层级内容
|
||||
sections: [{
|
||||
title: String,
|
||||
content: String,
|
||||
images: [String]
|
||||
}],
|
||||
|
||||
// 问答系统
|
||||
question: String,
|
||||
answer: String,
|
||||
options: [{
|
||||
text: String,
|
||||
isCorrect: Boolean
|
||||
}],
|
||||
|
||||
// 统计
|
||||
viewCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
collectionCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 付费与订阅
|
||||
isPremium: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
requiredPoints: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
price: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: 'CNY'
|
||||
},
|
||||
|
||||
// 状态
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'pending', 'approved', 'rejected'],
|
||||
default: 'draft',
|
||||
index: true
|
||||
},
|
||||
|
||||
// 审核信息
|
||||
reviewComment: String,
|
||||
reviewedAt: Date,
|
||||
reviewerId: String,
|
||||
|
||||
// 排序
|
||||
sortOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
KnowledgeSchema.index({ dim: 1, sortOrder: 1 })
|
||||
KnowledgeSchema.index({ status: 1, createdAt: -1 })
|
||||
KnowledgeSchema.index({ category: 1 })
|
||||
|
||||
// 中间件:保存前更新 updatedAt
|
||||
KnowledgeSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
next()
|
||||
})
|
||||
|
||||
module.exports = mongoose.model('Knowledge', KnowledgeSchema)
|
||||
@@ -0,0 +1,79 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 关卡配置模型
|
||||
*/
|
||||
const LevelSchema = new Schema({
|
||||
// 关卡基本信息
|
||||
dimension: {
|
||||
type: Number,
|
||||
required: true,
|
||||
enum: [1, 2, 3, 4, 5],
|
||||
index: true
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
|
||||
// 关卡难度配置
|
||||
difficulty: {
|
||||
type: String,
|
||||
enum: ['easy', 'normal', 'hard', 'expert'],
|
||||
default: 'normal'
|
||||
},
|
||||
|
||||
// 通关条件
|
||||
requirements: {
|
||||
score: { type: Number, default: 0 },
|
||||
timeLimit: { type: Number, default: 0 }, // 秒,0表示无限制
|
||||
collectibles: { type: Number, default: 0 } // 需要收集的物品数量
|
||||
},
|
||||
|
||||
// 奖励配置
|
||||
rewards: {
|
||||
exp: { type: Number, default: 100 },
|
||||
coins: { type: Number, default: 50 },
|
||||
unlockNext: { type: Boolean, default: true }
|
||||
},
|
||||
|
||||
// 关卡描述
|
||||
title: String,
|
||||
description: String,
|
||||
tips: [String], // 游戏提示
|
||||
|
||||
// 状态
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isLocked: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// 排序
|
||||
sortOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
})
|
||||
|
||||
// 复合索引:维度 + 关卡等级
|
||||
LevelSchema.index({ dimension: 1, level: 1 }, { unique: true })
|
||||
LevelSchema.index({ dimension: 1, isActive: 1, sortOrder: 1 })
|
||||
|
||||
module.exports = mongoose.model('Level', LevelSchema)
|
||||
@@ -0,0 +1,142 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 登录日志模型
|
||||
* 记录用户的登录/注册行为
|
||||
*/
|
||||
const LoginLogSchema = new Schema({
|
||||
// 用户标识
|
||||
userId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
openid: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// 登录类型
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['register', 'login', 'auto_login'],
|
||||
required: true
|
||||
},
|
||||
|
||||
// 登录方式
|
||||
method: {
|
||||
type: String,
|
||||
enum: ['wechat_miniapp', 'wechat_webview', 'wechat_oauth', 'username', 'other'],
|
||||
required: true
|
||||
},
|
||||
|
||||
// IP地址
|
||||
ip: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 终端信息
|
||||
userAgent: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 设备信息
|
||||
device: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 操作系统
|
||||
os: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 浏览器
|
||||
browser: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 登录来源
|
||||
source: {
|
||||
type: String,
|
||||
enum: ['miniapp', 'webview', 'h5', 'admin', 'other'],
|
||||
default: 'other'
|
||||
},
|
||||
|
||||
// 登录结果
|
||||
success: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// 失败原因
|
||||
failReason: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 地理位置(可选)
|
||||
location: {
|
||||
country: { type: String, default: '' },
|
||||
province: { type: String, default: '' },
|
||||
city: { type: String, default: '' }
|
||||
},
|
||||
|
||||
// 登录时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
}
|
||||
}, {
|
||||
timestamps: false
|
||||
})
|
||||
|
||||
// 索引
|
||||
LoginLogSchema.index({ userId: 1, createdAt: -1 })
|
||||
LoginLogSchema.index({ openid: 1, createdAt: -1 })
|
||||
LoginLogSchema.index({ type: 1, createdAt: -1 })
|
||||
LoginLogSchema.index({ method: 1, createdAt: -1 })
|
||||
|
||||
// 静态方法:获取用户的登录历史
|
||||
LoginLogSchema.statics.getUserLoginHistory = function(userId, limit = 10) {
|
||||
return this.find({ userId })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.lean()
|
||||
}
|
||||
|
||||
// 静态方法:获取最近的登录日志
|
||||
LoginLogSchema.statics.getRecentLogs = function(limit = 50) {
|
||||
return this.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.populate('userId', 'nickName avatarUrl')
|
||||
.lean()
|
||||
}
|
||||
|
||||
// 静态方法:统计登录数据
|
||||
LoginLogSchema.statics.getLoginStats = function(startDate, endDate) {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdAt: { $gte: startDate, $lte: endDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$type',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('LoginLog', LoginLogSchema)
|
||||
@@ -0,0 +1,120 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 订单模型
|
||||
*/
|
||||
const OrderSchema = new Schema({
|
||||
// 订单基本信息
|
||||
orderNo: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// 用户信息
|
||||
openid: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true
|
||||
},
|
||||
|
||||
// 商品信息
|
||||
productId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
productName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
amount: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
|
||||
// 支付信息
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'paid', 'completed', 'failed', 'refunded'],
|
||||
default: 'pending',
|
||||
index: true
|
||||
},
|
||||
prepayId: String,
|
||||
transactionId: String,
|
||||
|
||||
// 微信支付参数(用于调试)
|
||||
paymentParams: Schema.Types.Mixed,
|
||||
rawResult: Schema.Types.Mixed,
|
||||
|
||||
// 审核信息(管理后台)
|
||||
reviewComment: String,
|
||||
reviewedAt: Date,
|
||||
reviewerId: String,
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
},
|
||||
paidAt: Date,
|
||||
completedAt: Date,
|
||||
refundedAt: Date
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
OrderSchema.index({ openid: 1, createdAt: -1 })
|
||||
OrderSchema.index({ status: 1, createdAt: -1 })
|
||||
OrderSchema.index({ userId: 1, createdAt: -1 }) // 优化用户订单查询
|
||||
|
||||
// 静态方法:获取今日订单数
|
||||
OrderSchema.statics.getTodayCount = async function() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
return this.countDocuments({
|
||||
createdAt: { $gte: today },
|
||||
status: 'paid'
|
||||
})
|
||||
}
|
||||
|
||||
// 静态方法:获取订单趋势(7天)
|
||||
OrderSchema.statics.getWeekTrend = async function() {
|
||||
const weekAgo = new Date()
|
||||
weekAgo.setDate(weekAgo.getDate() - 7)
|
||||
weekAgo.setHours(0, 0, 0, 0)
|
||||
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdAt: { $gte: weekAgo },
|
||||
status: 'paid'
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: '%Y-%m-%d', date: '$createdAt' }
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
revenue: { $sum: '$amount' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { '_id': 1 }
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('Order', OrderSchema)
|
||||
@@ -0,0 +1,25 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
const ShareRecordSchema = new mongoose.Schema({
|
||||
openid: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true
|
||||
},
|
||||
shareType: {
|
||||
type: String,
|
||||
enum: ['app', 'dim1', 'dim2', 'dim3', 'dim4', 'dim5'],
|
||||
default: 'app'
|
||||
},
|
||||
score: { type: Number, default: 0 },
|
||||
sharedAt: { type: Date, default: Date.now }
|
||||
})
|
||||
|
||||
ShareRecordSchema.index({ sharedAt: -1 })
|
||||
|
||||
module.exports = mongoose.model('ShareRecord', ShareRecordSchema)
|
||||
@@ -0,0 +1,109 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 商品模型
|
||||
*/
|
||||
const ShopItemSchema = new Schema({
|
||||
// 基本信息
|
||||
itemId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// 价格(单位:分)
|
||||
price: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1
|
||||
},
|
||||
originalPrice: {
|
||||
type: Number
|
||||
},
|
||||
|
||||
// 商品类型
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['skin', 'noad', 'subscription'],
|
||||
required: true
|
||||
},
|
||||
|
||||
// 订阅天数(仅订阅商品)
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 30
|
||||
},
|
||||
|
||||
// 图片
|
||||
imageUrl: String,
|
||||
previewUrl: String,
|
||||
|
||||
// 统计
|
||||
purchaseCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 状态
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive', 'discontinued'],
|
||||
default: 'active',
|
||||
index: true
|
||||
},
|
||||
|
||||
// 排序
|
||||
sortOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 促销
|
||||
isOnSale: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
saleEndTime: Date,
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
ShopItemSchema.index({ status: 1, sortOrder: 1 })
|
||||
ShopItemSchema.index({ type: 1 })
|
||||
|
||||
// 虚拟字段:是否在促销中
|
||||
ShopItemSchema.virtual('isOnSaleActive').get(function() {
|
||||
return this.isOnSale && this.saleEndTime && new Date() < this.saleEndTime
|
||||
})
|
||||
|
||||
// 中间件:保存前更新 updatedAt
|
||||
ShopItemSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
next()
|
||||
})
|
||||
|
||||
module.exports = mongoose.model('ShopItem', ShopItemSchema)
|
||||
@@ -0,0 +1,33 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const TrendSchema = new Schema({
|
||||
title: { type: String, required: true, trim: true },
|
||||
summary: { type: String },
|
||||
source: { type: String, default: '未知来源' },
|
||||
sourceUrl: { type: String },
|
||||
category: {
|
||||
type: String,
|
||||
enum: ['产品', '论文', '工具', '公司', '政策', '其他'],
|
||||
default: '其他'
|
||||
},
|
||||
tags: [String],
|
||||
hot: { type: Boolean, default: false },
|
||||
picked: { type: Boolean, default: false },
|
||||
newsDate: { type: Date, default: Date.now, index: true },
|
||||
viewCount: { type: Number, default: 0 },
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'published'],
|
||||
default: 'published'
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
TrendSchema.index({ status: 1, newsDate: -1 });
|
||||
TrendSchema.index({ category: 1, newsDate: -1 });
|
||||
|
||||
module.exports = mongoose.model('Trend', TrendSchema);
|
||||
@@ -0,0 +1,305 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
const bcrypt = require('bcryptjs')
|
||||
|
||||
/**
|
||||
* 用户探索数据结构
|
||||
*/
|
||||
const ExploreDataSchema = new Schema({
|
||||
dim1: {
|
||||
completed: { type: Boolean, default: false },
|
||||
bestScore: { type: Number, default: 0 },
|
||||
collectedEggs: { type: Number, default: 0 },
|
||||
totalLength: { type: Number, default: 0 }
|
||||
},
|
||||
dim2: {
|
||||
completed: { type: Boolean, default: false },
|
||||
bestArea: { type: Number, default: 0 },
|
||||
createdShapes: { type: Number, default: 0 }
|
||||
},
|
||||
dim3: {
|
||||
completed: { type: Boolean, default: false },
|
||||
exploredFaces: { type: Number, default: 0 },
|
||||
rotationTime: { type: Number, default: 0 },
|
||||
bestScore: { type: Number, default: 0 }
|
||||
},
|
||||
dim4: {
|
||||
completed: { type: Boolean, default: false },
|
||||
bestScore: { type: Number, default: 0 },
|
||||
exploredEvents: { type: Number, default: 0 }
|
||||
},
|
||||
dim5: {
|
||||
completed: { type: Boolean, default: false },
|
||||
bestScore: { type: Number, default: 0 },
|
||||
exploredThoughts: { type: Number, default: 0 }
|
||||
}
|
||||
}, { _id: false })
|
||||
|
||||
/**
|
||||
* 用户模型
|
||||
*/
|
||||
const UserSchema = new Schema({
|
||||
// 微信用户标识
|
||||
openid: {
|
||||
type: String,
|
||||
required: false,
|
||||
unique: true,
|
||||
sparse: true,
|
||||
index: true
|
||||
},
|
||||
unionid: {
|
||||
type: String,
|
||||
index: true
|
||||
},
|
||||
|
||||
// H5环境用户名密码登录
|
||||
username: {
|
||||
type: String,
|
||||
unique: true,
|
||||
sparse: true,
|
||||
index: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
select: false
|
||||
},
|
||||
|
||||
// 基本信息
|
||||
nickName: {
|
||||
type: String,
|
||||
default: '星辰旅行者'
|
||||
},
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 探索数据
|
||||
exploreData: {
|
||||
type: ExploreDataSchema,
|
||||
default: () => ({})
|
||||
},
|
||||
|
||||
// 分数(冗余字段,便于排行榜查询)
|
||||
totalScore: { type: Number, default: 0 },
|
||||
dim1Score: { type: Number, default: 0 },
|
||||
dim2Score: { type: Number, default: 0 },
|
||||
dim3Score: { type: Number, default: 0 },
|
||||
dim4Score: { type: Number, default: 0 },
|
||||
dim5Score: { type: Number, default: 0 },
|
||||
|
||||
// 已解锁维度
|
||||
unlockedDims: [{
|
||||
type: Number,
|
||||
enum: [1, 2, 3, 4, 5]
|
||||
}],
|
||||
|
||||
// 商品相关
|
||||
ownedSkins: [{
|
||||
type: String
|
||||
}],
|
||||
purchasedItems: [{
|
||||
type: String
|
||||
}],
|
||||
|
||||
// 会员状态
|
||||
isAdFree: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isSubscriber: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
subscribeExpiry: {
|
||||
type: Date
|
||||
},
|
||||
|
||||
// 收藏与成就
|
||||
collectedKnowledge: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Knowledge'
|
||||
}],
|
||||
achievements: [{
|
||||
type: String
|
||||
}],
|
||||
|
||||
// 设置
|
||||
settings: {
|
||||
voiceEnabled: { type: Boolean, default: true },
|
||||
difficulty: {
|
||||
type: String,
|
||||
enum: ['easy', 'normal', 'hard'],
|
||||
default: 'normal'
|
||||
}
|
||||
},
|
||||
|
||||
// 探索等级系统
|
||||
level: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
levelPoints: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 时间戳
|
||||
totalPlayTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
lastLoginAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
UserSchema.index({ totalScore: -1 })
|
||||
UserSchema.index({ dim1Score: -1 })
|
||||
UserSchema.index({ dim2Score: -1 })
|
||||
UserSchema.index({ dim3Score: -1 })
|
||||
UserSchema.index({ dim4Score: -1 })
|
||||
UserSchema.index({ dim5Score: -1 })
|
||||
UserSchema.index({ createdAt: -1 })
|
||||
UserSchema.index({ lastLoginAt: -1 }) // 用于活跃用户统计
|
||||
|
||||
// 虚拟字段:是否是新用户(24小时内注册)
|
||||
UserSchema.virtual('isNew').get(function() {
|
||||
return Date.now() - this.createdAt < 24 * 60 * 60 * 1000
|
||||
})
|
||||
|
||||
// 中间件:保存前加密密码(仅针对username/password登录)
|
||||
UserSchema.pre('save', async function(next) {
|
||||
if (!this.isModified('password')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10)
|
||||
this.password = await bcrypt.hash(this.password, salt)
|
||||
next()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 实例方法:验证密码
|
||||
UserSchema.methods.validatePassword = async function(password) {
|
||||
return bcrypt.compare(password, this.password)
|
||||
}
|
||||
|
||||
// 实例方法:计算总分
|
||||
UserSchema.methods.calculateTotalScore = function() {
|
||||
this.totalScore =
|
||||
(this.exploreData.dim1?.bestScore || 0) +
|
||||
(this.exploreData.dim2?.bestArea || 0) +
|
||||
(this.exploreData.dim3?.bestScore || 0) +
|
||||
(this.exploreData.dim4?.bestScore || 0) +
|
||||
(this.exploreData.dim5?.bestScore || 0)
|
||||
|
||||
// 同步维度分数
|
||||
this.dim1Score = this.exploreData.dim1?.bestScore || 0
|
||||
this.dim2Score = this.exploreData.dim2?.bestArea || 0
|
||||
this.dim3Score = this.exploreData.dim3?.bestScore || 0
|
||||
this.dim4Score = this.exploreData.dim4?.bestScore || 0
|
||||
this.dim5Score = this.exploreData.dim5?.bestScore || 0
|
||||
|
||||
return this.totalScore
|
||||
}
|
||||
|
||||
// 实例方法:计算等级
|
||||
UserSchema.methods.calculateLevel = function() {
|
||||
// 等级计算公式:基于总分和探索时长
|
||||
// 基础分:每100分 = 1级
|
||||
const scorePoints = Math.floor(this.totalScore / 100)
|
||||
// 时长分:每1小时 = 1级
|
||||
const timePoints = Math.floor(this.totalPlayTime / 60)
|
||||
// 成就分:每个成就 = 2级
|
||||
const achievementPoints = (this.achievements?.length || 0) * 2
|
||||
// 收藏分:每5个收藏 = 1级
|
||||
const collectionPoints = Math.floor((this.collectedKnowledge?.length || 0) / 5)
|
||||
|
||||
this.levelPoints = scorePoints + timePoints + achievementPoints + collectionPoints
|
||||
|
||||
// 等级阈值表
|
||||
const levelThresholds = [
|
||||
0, 5, 15, 30, 50, 75, 105, 140, 180, 225,
|
||||
275, 330, 390, 455, 525, 600, 680, 765, 855, 950, 1050
|
||||
]
|
||||
|
||||
// 计算等级
|
||||
let newLevel = 1
|
||||
for (let i = 0; i < levelThresholds.length; i++) {
|
||||
if (this.levelPoints >= levelThresholds[i]) {
|
||||
newLevel = i + 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
this.level = newLevel
|
||||
return this.level
|
||||
}
|
||||
|
||||
// 实例方法:获取等级信息
|
||||
UserSchema.methods.getLevelInfo = function() {
|
||||
const levelNames = [
|
||||
'星辰旅者', '维度学徒', '空间探索者', '几何学者', '维度行者',
|
||||
'时空旅人', '多维大师', '宇宙探索者', '维度掌控者', '空间主宰',
|
||||
'维度领主', '宇宙行者', '时空主宰', '维度之神', '宇宙之主',
|
||||
'维度创世者', '空间造物主', '宇宙掌控者', '维度至尊', '宇宙之神'
|
||||
]
|
||||
|
||||
const levelThresholds = [
|
||||
0, 5, 15, 30, 50, 75, 105, 140, 180, 225,
|
||||
275, 330, 390, 455, 525, 600, 680, 765, 855, 950, 1050
|
||||
]
|
||||
|
||||
const currentLevel = this.level || 1
|
||||
const currentPoints = this.levelPoints || 0
|
||||
const nextLevelPoints = levelThresholds[currentLevel] || levelThresholds[levelThresholds.length - 1]
|
||||
const progress = Math.min(100, Math.floor((currentPoints / nextLevelPoints) * 100))
|
||||
|
||||
return {
|
||||
level: currentLevel,
|
||||
name: levelNames[Math.min(currentLevel - 1, levelNames.length - 1)],
|
||||
points: currentPoints,
|
||||
nextLevelPoints: nextLevelPoints,
|
||||
progress: progress,
|
||||
totalProgress: Math.min(100, Math.floor((currentPoints / 1050) * 100))
|
||||
}
|
||||
}
|
||||
|
||||
// 静态方法:获取排行榜
|
||||
UserSchema.statics.getLeaderboard = function(dim = null, limit = 50) {
|
||||
const sortField = dim ? `dim${dim}Score` : 'totalScore'
|
||||
return this.find()
|
||||
.select('openid nickName avatarUrl totalScore dim1Score dim2Score dim3Score dim4Score dim5Score unlockedDims')
|
||||
.sort({ [sortField]: -1 })
|
||||
.limit(limit)
|
||||
.lean()
|
||||
}
|
||||
|
||||
// 中间件:保存前更新 updatedAt、计算总分和等级
|
||||
UserSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
this.calculateTotalScore()
|
||||
this.calculateLevel()
|
||||
next()
|
||||
})
|
||||
|
||||
module.exports = mongoose.model('User', UserSchema)
|
||||
@@ -0,0 +1,38 @@
|
||||
const User = require('./User')
|
||||
const Order = require('./Order')
|
||||
const Gallery = require('./Gallery')
|
||||
const Knowledge = require('./Knowledge')
|
||||
const ShopItem = require('./ShopItem')
|
||||
const Admin = require('./Admin')
|
||||
const AdminLog = require('./AdminLog')
|
||||
const ShareRecord = require('./ShareRecord')
|
||||
const BGM = require('./BGM')
|
||||
const Level = require('./Level')
|
||||
const AIChatQuota = require('./AIChatQuota')
|
||||
const AIModel = require('./AIModel')
|
||||
const LoginLog = require('./LoginLog')
|
||||
const Feedback = require('./Feedback')
|
||||
const Trend = require('./Trend')
|
||||
|
||||
// 拼音探索模块模型
|
||||
const pinyinModels = require('./pinyin')
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
Order,
|
||||
Gallery,
|
||||
Knowledge,
|
||||
ShopItem,
|
||||
Admin,
|
||||
AdminLog,
|
||||
ShareRecord,
|
||||
BGM,
|
||||
Level,
|
||||
AIChatQuota,
|
||||
AIModel,
|
||||
LoginLog,
|
||||
Feedback,
|
||||
Trend,
|
||||
// 拼音探索模型
|
||||
...pinyinModels
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* 拼音探索成就模型
|
||||
* 定义可获得的成就及其条件
|
||||
*/
|
||||
const pinyinAchievementSchema = new mongoose.Schema({
|
||||
// 成就代码
|
||||
code: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// 成就名称
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// 成就描述
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// 成就图标
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 成就类型
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['explore', 'game', 'collection', 'streak', 'special'],
|
||||
default: 'explore'
|
||||
},
|
||||
|
||||
// 达成条件
|
||||
condition: {
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: [
|
||||
'explore_count', // 探索数量
|
||||
'collect_count', // 收集数量
|
||||
'game_count', // 游戏次数
|
||||
'game_score', // 游戏分数
|
||||
'streak_days', // 连续天数
|
||||
'complete_symbol', // 完成指定拼音
|
||||
'complete_type', // 完成某类型所有拼音
|
||||
'explore_all' // 探索所有内容
|
||||
]
|
||||
},
|
||||
value: { type: Number, required: true }, // 条件值
|
||||
symbol: { type: String }, // 特定拼音(可选)
|
||||
symbolType: { type: String } // 特定类型(可选)
|
||||
},
|
||||
|
||||
// 奖励
|
||||
reward: {
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['stone', 'badge', 'theme', 'none'],
|
||||
default: 'none'
|
||||
},
|
||||
value: { type: Number, default: 0 },
|
||||
item: { type: String, default: '' }
|
||||
},
|
||||
|
||||
// 排序
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 是否启用
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// 创建时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
|
||||
// 更新时间
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// 索引
|
||||
pinyinAchievementSchema.index({ type: 1, order: 1 });
|
||||
pinyinAchievementSchema.index({ isActive: 1 });
|
||||
pinyinAchievementSchema.index({ code: 1 });
|
||||
|
||||
// 更新中间件
|
||||
pinyinAchievementSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('PinyinAchievement', pinyinAchievementSchema);
|
||||
@@ -0,0 +1,120 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* 拼音内容模型
|
||||
* 存储拼音字母的基础信息、音频、口型图、关联词语等
|
||||
*/
|
||||
const pinyinContentSchema = new mongoose.Schema({
|
||||
// 拼音符号,如 "b"
|
||||
symbol: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// 拼音类型:initial(声母)/final(韵母)/overall(整体认读)
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['initial', 'final', 'overall']
|
||||
},
|
||||
|
||||
// 拼音名称,如 "玻"
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// 发音音频URL
|
||||
audioUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 口型示意图URL
|
||||
mouthImage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 发音方法描述
|
||||
pronunciation: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 显示顺序
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 是否免费
|
||||
isFree: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// 状态:active(启用)/inactive(禁用)
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive'],
|
||||
default: 'active'
|
||||
},
|
||||
|
||||
// 相关词语
|
||||
words: [{
|
||||
word: { type: String, required: true }, // 汉字
|
||||
pinyin: { type: String, required: true }, // 拼音
|
||||
image: { type: String, default: '' }, // 图片URL
|
||||
audioUrl: { type: String, default: '' }, // 音频URL
|
||||
meaning: { type: String, default: '' } // 释义
|
||||
}],
|
||||
|
||||
// 关联游戏配置
|
||||
games: [{
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['match', 'tone', 'find', 'puzzle', 'mimic', 'runner']
|
||||
},
|
||||
config: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
isEnabled: { type: Boolean, default: true }
|
||||
}],
|
||||
|
||||
// 探索区域配置
|
||||
exploreAreas: {
|
||||
audio: { type: Boolean, default: true }, // 声音发现
|
||||
mouth: { type: Boolean, default: true }, // 口型观察
|
||||
speak: { type: Boolean, default: true }, // 语音互动
|
||||
write: { type: Boolean, default: true }, // 书写体验
|
||||
game: { type: Boolean, default: true }, // 趣味互动
|
||||
words: { type: Boolean, default: true } // 词语发现
|
||||
},
|
||||
|
||||
// 创建时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
|
||||
// 更新时间
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// 索引
|
||||
pinyinContentSchema.index({ type: 1, order: 1 });
|
||||
pinyinContentSchema.index({ status: 1 });
|
||||
pinyinContentSchema.index({ isFree: 1 });
|
||||
|
||||
// 更新中间件
|
||||
pinyinContentSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('PinyinContent', pinyinContentSchema);
|
||||
@@ -0,0 +1,142 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* 拼音游戏记录模型
|
||||
* 记录用户的游戏行为和成绩
|
||||
*/
|
||||
const pinyinGameRecordSchema = new mongoose.Schema({
|
||||
// 用户ID
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
|
||||
// 游戏类型
|
||||
gameType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['match', 'tone', 'find', 'puzzle', 'mimic', 'runner']
|
||||
},
|
||||
|
||||
// 游戏难度
|
||||
difficulty: {
|
||||
type: String,
|
||||
enum: ['easy', 'normal', 'hard'],
|
||||
default: 'normal'
|
||||
},
|
||||
|
||||
// 得分
|
||||
score: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 游戏时长(秒)
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 正确数
|
||||
correctCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 错误数
|
||||
wrongCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 准确率
|
||||
accuracy: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 游戏详情
|
||||
details: {
|
||||
type: mongoose.Schema.Types.Mixed,
|
||||
default: {}
|
||||
},
|
||||
|
||||
// 是否完成
|
||||
isCompleted: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// 游戏时间
|
||||
playedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
|
||||
// 创建时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// 索引
|
||||
pinyinGameRecordSchema.index({ userId: 1, gameType: 1 });
|
||||
pinyinGameRecordSchema.index({ userId: 1, playedAt: -1 });
|
||||
pinyinGameRecordSchema.index({ gameType: 1, score: -1 });
|
||||
|
||||
// 静态方法:获取用户游戏统计
|
||||
pinyinGameRecordSchema.statics.getUserStats = async function(userId) {
|
||||
const stats = await this.aggregate([
|
||||
{ $match: { userId: mongoose.Types.ObjectId(userId) } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$gameType',
|
||||
totalGames: { $sum: 1 },
|
||||
totalScore: { $sum: '$score' },
|
||||
avgScore: { $avg: '$score' },
|
||||
maxScore: { $max: '$score' },
|
||||
totalDuration: { $sum: '$duration' },
|
||||
avgAccuracy: { $avg: '$accuracy' }
|
||||
}
|
||||
}
|
||||
]);
|
||||
return stats;
|
||||
};
|
||||
|
||||
// 静态方法:获取排行榜
|
||||
pinyinGameRecordSchema.statics.getLeaderboard = async function(gameType, limit = 10) {
|
||||
const leaderboard = await this.aggregate([
|
||||
{ $match: { gameType: gameType } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$userId',
|
||||
maxScore: { $max: '$score' },
|
||||
totalGames: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { maxScore: -1 } },
|
||||
{ $limit: limit },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: '_id',
|
||||
foreignField: '_id',
|
||||
as: 'user'
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
userId: '$_id',
|
||||
maxScore: 1,
|
||||
totalGames: 1,
|
||||
nickname: { $arrayElemAt: ['$user.nickname', 0] },
|
||||
avatar: { $arrayElemAt: ['$user.avatar', 0] }
|
||||
}
|
||||
}
|
||||
]);
|
||||
return leaderboard;
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('PinyinGameRecord', pinyinGameRecordSchema);
|
||||
@@ -0,0 +1,189 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* 用户拼音探索进度模型
|
||||
* 记录用户的探索进度、收集的能量石、成就等
|
||||
*/
|
||||
const pinyinProgressSchema = new mongoose.Schema({
|
||||
// 用户ID
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
|
||||
// 已探索的拼音
|
||||
exploredSymbols: [{
|
||||
symbol: { type: String, required: true }, // 拼音符号
|
||||
exploredAt: { type: Date, default: Date.now }, // 首次探索时间
|
||||
completedAreas: [{ type: String }], // 完成的探索区域
|
||||
isCollected: { type: Boolean, default: false }, // 是否收集能量石
|
||||
collectedAt: { type: Date }, // 收集时间
|
||||
lastExploredAt: { type: Date } // 最后探索时间
|
||||
}],
|
||||
|
||||
// 当前正在探索的拼音
|
||||
currentSymbol: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
|
||||
// 总探索数
|
||||
totalExplored: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 收集的能量石数量
|
||||
totalStones: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 获得的成就
|
||||
achievements: [{
|
||||
code: { type: String, required: true }, // 成就代码
|
||||
obtainedAt: { type: Date, default: Date.now } // 获得时间
|
||||
}],
|
||||
|
||||
// 每日统计
|
||||
dailyStats: [{
|
||||
date: { type: Date, required: true }, // 日期
|
||||
exploreCount: { type: Number, default: 0 }, // 探索次数
|
||||
gameCount: { type: Number, default: 0 }, // 游戏次数
|
||||
duration: { type: Number, default: 0 } // 时长(分钟)
|
||||
}],
|
||||
|
||||
// 连续探索天数
|
||||
streakDays: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 最后探索日期
|
||||
lastExploreDate: {
|
||||
type: Date
|
||||
},
|
||||
|
||||
// 创建时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
|
||||
// 更新时间
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// 索引
|
||||
pinyinProgressSchema.index({ userId: 1 });
|
||||
pinyinProgressSchema.index({ totalExplored: -1 });
|
||||
|
||||
// 更新中间件
|
||||
pinyinProgressSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
// 方法:获取指定拼音的探索记录
|
||||
pinyinProgressSchema.methods.getSymbolProgress = function(symbol) {
|
||||
return this.exploredSymbols.find(s => s.symbol === symbol);
|
||||
};
|
||||
|
||||
// 方法:检查是否已探索指定拼音
|
||||
pinyinProgressSchema.methods.hasExplored = function(symbol) {
|
||||
return this.exploredSymbols.some(s => s.symbol === symbol);
|
||||
};
|
||||
|
||||
// 方法:检查是否完成指定拼音的所有区域
|
||||
pinyinProgressSchema.methods.isSymbolCompleted = function(symbol) {
|
||||
const record = this.getSymbolProgress(symbol);
|
||||
if (!record) return false;
|
||||
return record.completedAreas.length >= 6; // 6个探索区域
|
||||
};
|
||||
|
||||
// 方法:添加探索记录
|
||||
pinyinProgressSchema.methods.addExplore = function(symbol, area) {
|
||||
let record = this.getSymbolProgress(symbol);
|
||||
|
||||
if (!record) {
|
||||
record = {
|
||||
symbol: symbol,
|
||||
exploredAt: new Date(),
|
||||
completedAreas: [],
|
||||
isCollected: false
|
||||
};
|
||||
this.exploredSymbols.push(record);
|
||||
this.totalExplored += 1;
|
||||
}
|
||||
|
||||
if (!record.completedAreas.includes(area)) {
|
||||
record.completedAreas.push(area);
|
||||
}
|
||||
|
||||
record.lastExploredAt = new Date();
|
||||
this.currentSymbol = symbol;
|
||||
this.lastExploreDate = new Date();
|
||||
};
|
||||
|
||||
// 方法:收集能量石
|
||||
pinyinProgressSchema.methods.collectStone = function(symbol) {
|
||||
const record = this.getSymbolProgress(symbol);
|
||||
if (record && !record.isCollected) {
|
||||
record.isCollected = true;
|
||||
record.collectedAt = new Date();
|
||||
this.totalStones += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 方法:添加成就
|
||||
pinyinProgressSchema.methods.addAchievement = function(code) {
|
||||
if (!this.achievements.some(a => a.code === code)) {
|
||||
this.achievements.push({
|
||||
code: code,
|
||||
obtainedAt: new Date()
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 方法:更新每日统计
|
||||
pinyinProgressSchema.methods.updateDailyStats = function(type, duration = 0) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let dailyStat = this.dailyStats.find(d => {
|
||||
const statDate = new Date(d.date);
|
||||
statDate.setHours(0, 0, 0, 0);
|
||||
return statDate.getTime() === today.getTime();
|
||||
});
|
||||
|
||||
if (!dailyStat) {
|
||||
dailyStat = {
|
||||
date: today,
|
||||
exploreCount: 0,
|
||||
gameCount: 0,
|
||||
duration: 0
|
||||
};
|
||||
this.dailyStats.push(dailyStat);
|
||||
}
|
||||
|
||||
if (type === 'explore') {
|
||||
dailyStat.exploreCount += 1;
|
||||
} else if (type === 'game') {
|
||||
dailyStat.gameCount += 1;
|
||||
}
|
||||
|
||||
if (duration > 0) {
|
||||
dailyStat.duration += duration;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('PinyinProgress', pinyinProgressSchema);
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 拼音探索模块模型导出
|
||||
*/
|
||||
module.exports = {
|
||||
PinyinContent: require('./PinyinContent'),
|
||||
PinyinProgress: require('./PinyinProgress'),
|
||||
PinyinAchievement: require('./PinyinAchievement'),
|
||||
PinyinGameRecord: require('./PinyinGameRecord')
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { AIChatQuota, AIModel } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取用户AI问答配额
|
||||
* GET /api/ai-chat/quota
|
||||
*/
|
||||
router.get('/quota', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const openid = req.openid || req.user?.openid
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
let quota = await AIChatQuota.findOne({ openid })
|
||||
|
||||
if (!quota) {
|
||||
// 创建新的配额记录,默认5次免费
|
||||
quota = new AIChatQuota({ openid })
|
||||
await quota.save()
|
||||
}
|
||||
|
||||
// 检查是否需要重置每日使用次数
|
||||
quota.resetDailyUsed()
|
||||
await quota.save()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
freeQuota: quota.freeQuota,
|
||||
sharedQuota: quota.sharedQuota,
|
||||
usedQuota: quota.usedQuota,
|
||||
remainingQuota: quota.getRemainingQuota(),
|
||||
dailyUsed: quota.dailyUsed
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取AI问答配额失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* AI问答接口
|
||||
* POST /api/ai-chat/ask
|
||||
*/
|
||||
router.post('/ask', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const openid = req.openid || req.user?.openid
|
||||
const { question, dimension = 'general' } = req.body
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
if (!question || question.trim().length === 0) {
|
||||
return ApiResponse.error(res, '请输入问题', 400)
|
||||
}
|
||||
|
||||
// 检查用户配额
|
||||
let quota = await AIChatQuota.findOne({ openid })
|
||||
|
||||
if (!quota) {
|
||||
quota = new AIChatQuota({ openid })
|
||||
await quota.save()
|
||||
}
|
||||
|
||||
if (!quota.hasQuota()) {
|
||||
return ApiResponse.error(res, '问答次数已用完,分享好友可获得更多次数', 403)
|
||||
}
|
||||
|
||||
// 获取当前使用的AI模型配置
|
||||
const aiModel = await AIModel.getCurrentModel()
|
||||
|
||||
let apiUrl, apiKey, modelId, modelConfig
|
||||
|
||||
if (aiModel) {
|
||||
// 使用数据库配置的模型
|
||||
apiUrl = aiModel.apiUrl
|
||||
apiKey = aiModel.apiKey
|
||||
modelId = aiModel.modelId
|
||||
modelConfig = aiModel.config
|
||||
} else {
|
||||
// 使用环境变量配置的模型
|
||||
apiUrl = process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions'
|
||||
apiKey = process.env.OPENAI_API_KEY
|
||||
modelId = process.env.OPENAI_MODEL || 'gpt-3.5-turbo'
|
||||
modelConfig = { temperature: 0.7, maxTokens: 800, topP: 1 }
|
||||
}
|
||||
|
||||
// 检查API配置
|
||||
if (!apiKey) {
|
||||
logger.error('AI API Key未配置')
|
||||
return ApiResponse.error(res, 'AI服务暂时不可用', 500)
|
||||
}
|
||||
|
||||
// 构建系统提示词
|
||||
const systemPrompt = getSystemPromptByDimension(dimension)
|
||||
|
||||
// 调用AI API
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question }
|
||||
],
|
||||
temperature: modelConfig.temperature || 0.7,
|
||||
max_tokens: modelConfig.maxTokens || 800,
|
||||
top_p: modelConfig.topP || 1
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('OpenAI API错误:', errorData)
|
||||
return ApiResponse.error(res, 'AI服务响应异常', 500)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const answer = data.choices[0].message.content
|
||||
|
||||
// 扣除一次使用次数
|
||||
await quota.useQuota()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
answer,
|
||||
remainingQuota: quota.getRemainingQuota()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('AI问答失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 分享获得问答次数
|
||||
* POST /api/ai-chat/share-gain
|
||||
*/
|
||||
router.post('/share-gain', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const openid = req.openid || req.user?.openid
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
let quota = await AIChatQuota.findOne({ openid })
|
||||
|
||||
if (!quota) {
|
||||
quota = new AIChatQuota({ openid })
|
||||
}
|
||||
|
||||
// 增加5次分享获得的次数
|
||||
const remainingQuota = await quota.addQuotaByShare(5)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '分享成功,获得5次问答机会',
|
||||
gainedQuota: 5,
|
||||
remainingQuota
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('分享获得次数失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 根据维度获取系统提示词
|
||||
*/
|
||||
function getSystemPromptByDimension(dimension) {
|
||||
const prompts = {
|
||||
dim1: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 起源和历史的问题。
|
||||
请用通俗易懂的语言解释人工智能发展史上的重要事件、人物和里程碑,如:
|
||||
- 图灵测试(1950)与艾伦·图灵的贡献
|
||||
- 达特茅斯会议(1956)——AI 的诞生
|
||||
- 深蓝 vs 卡斯帕罗夫(1997)
|
||||
- AlphaGo 与深度学习革命(2016)
|
||||
- GPT 系列与大模型时代的开启
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
dim2: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 技术发展的问题。
|
||||
请用通俗易懂的语言解释人工智能核心技术演进,如:
|
||||
- 机器学习与深度学习的基本原理
|
||||
- Transformer 架构与自注意力机制
|
||||
- 大规模预训练模型(GPT、BERT等)
|
||||
- 多模态 AI、MoE 架构
|
||||
- Agent 智能体技术
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
dim3: `你是"宇之然AI维度"的AI助手,专门解答关于当前 AI 行业格局的问题。
|
||||
请用通俗易懂的语言解释当前 AI 领域的最新动态,如:
|
||||
- 各大模型公司(OpenAI、Google、DeepSeek、Anthropic、Meta)的竞争格局
|
||||
- 开源 vs 闭源模型的生态对比
|
||||
- 具身智能与世界模型的最新进展
|
||||
- AI 应用在各行业的落地情况
|
||||
- Agent 智能体的发展现状
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
dim4: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 学习和工具使用的问题。
|
||||
请用通俗易懂的语言解释 AI 学习路线和工具使用方法,如:
|
||||
- 提示词工程(Prompt Engineering)入门
|
||||
- 检索增强生成(RAG)的原理与实践
|
||||
- 模型微调(Fine-tuning)基础
|
||||
- 主流 AI 开发框架(LangChain、Dify 等)
|
||||
- 常用 AI 工具推荐与使用技巧
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
dim5: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 趋势和前沿动态的问题。
|
||||
请用通俗易懂的语言介绍 AI 领域的最新趋势,如:
|
||||
- 最新 AI 产品发布动态
|
||||
- 前沿论文与研究方向
|
||||
- 行业趋势分析与预测
|
||||
- AI 政策法规与伦理讨论
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
general: `你是"宇之然AI维度"的AI助手,致力于帮助用户系统化理解人工智能。
|
||||
你可以回答关于 AI 的任何问题,包括但不限于:
|
||||
- AI 历史与起源
|
||||
- 核心技术原理
|
||||
- 当前行业格局
|
||||
- 学习路线与工具使用
|
||||
- 最新趋势与前沿动态
|
||||
你的回答要简洁、通俗易懂,适合普通用户理解。如果用户问的是某个具体维度的知识,可以引导用户去对应的维度模块深入学习。`
|
||||
}
|
||||
|
||||
return prompts[dimension] || prompts.general
|
||||
}
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,351 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { AIModel } = require('../models')
|
||||
const { authMiddleware, requirePermission } = require('../middleware/auth')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// 所有路由都需要认证
|
||||
router.use(authMiddleware)
|
||||
|
||||
/**
|
||||
* 获取当前使用的模型(公开接口,用于AI问答)
|
||||
* GET /api/ai-model/current
|
||||
*/
|
||||
router.get('/current', async (req, res) => {
|
||||
try {
|
||||
const model = await AIModel.getCurrentModel()
|
||||
|
||||
if (!model) {
|
||||
// 如果没有配置模型,返回环境变量中的默认配置
|
||||
return ApiResponse.success(res, {
|
||||
name: '默认模型',
|
||||
modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo',
|
||||
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
|
||||
apiKey: process.env.OPENAI_API_KEY || '',
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
maxTokens: 800,
|
||||
topP: 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 返回模型信息(不返回API Key)
|
||||
return ApiResponse.success(res, {
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
config: model.config
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取当前模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取模型列表(管理后台)
|
||||
* GET /api/ai-model/list
|
||||
*/
|
||||
router.get('/list', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 10, keyword = '' } = req.query
|
||||
|
||||
const query = keyword
|
||||
? { $or: [
|
||||
{ name: { $regex: keyword, $options: 'i' } },
|
||||
{ modelId: { $regex: keyword, $options: 'i' } }
|
||||
]}
|
||||
: {}
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
AIModel.find(query)
|
||||
.sort({ isDefault: -1, priority: 1, createdAt: -1 })
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(parseInt(pageSize)),
|
||||
AIModel.countDocuments(query)
|
||||
])
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取模型列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取所有启用的模型(下拉选择用)
|
||||
* GET /api/ai-model/active
|
||||
*/
|
||||
router.get('/active', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const models = await AIModel.getActiveModels()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: models.map(m => ({
|
||||
_id: m._id,
|
||||
name: m.name,
|
||||
modelId: m.modelId,
|
||||
isDefault: m.isDefault
|
||||
}))
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取启用模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取模型详情
|
||||
* GET /api/ai-model/:id
|
||||
*/
|
||||
router.get('/:id', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, { model })
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取模型详情失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建模型
|
||||
* POST /api/ai-model
|
||||
*/
|
||||
router.post('/', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { name, modelId, apiUrl, apiKey, description, config, priority } = req.body
|
||||
|
||||
// 验证必填字段
|
||||
if (!name || !modelId || !apiUrl || !apiKey) {
|
||||
return ApiResponse.error(res, '请填写所有必填字段', 400)
|
||||
}
|
||||
|
||||
// 检查modelId是否已存在
|
||||
const existingModel = await AIModel.findOne({ modelId })
|
||||
if (existingModel) {
|
||||
return ApiResponse.error(res, '该模型ID已存在', 400)
|
||||
}
|
||||
|
||||
const model = new AIModel({
|
||||
name,
|
||||
modelId,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
description,
|
||||
config,
|
||||
priority
|
||||
})
|
||||
|
||||
await model.save()
|
||||
|
||||
logger.info('创建AI模型:', { name, modelId, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '创建成功',
|
||||
model: {
|
||||
_id: model._id,
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
isDefault: model.isDefault,
|
||||
isActive: model.isActive
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('创建模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新模型
|
||||
* PUT /api/ai-model/:id
|
||||
*/
|
||||
router.put('/:id', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { name, modelId, apiUrl, apiKey, description, isActive, isDefault, config, priority } = req.body
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
// 如果修改了modelId,检查是否与其他模型冲突
|
||||
if (modelId && modelId !== model.modelId) {
|
||||
const existingModel = await AIModel.findOne({ modelId, _id: { $ne: id } })
|
||||
if (existingModel) {
|
||||
return ApiResponse.error(res, '该模型ID已存在', 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if (name) model.name = name
|
||||
if (modelId) model.modelId = modelId
|
||||
if (apiUrl) model.apiUrl = apiUrl
|
||||
if (apiKey) model.apiKey = apiKey
|
||||
if (description !== undefined) model.description = description
|
||||
if (isActive !== undefined) model.isActive = isActive
|
||||
if (isDefault !== undefined) model.isDefault = isDefault
|
||||
if (config) model.config = { ...model.config, ...config }
|
||||
if (priority !== undefined) model.priority = priority
|
||||
|
||||
await model.save()
|
||||
|
||||
logger.info('更新AI模型:', { id, name: model.name, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '更新成功',
|
||||
model: {
|
||||
_id: model._id,
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
isDefault: model.isDefault,
|
||||
isActive: model.isActive
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('更新模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除模型
|
||||
* DELETE /api/ai-model/:id
|
||||
*/
|
||||
router.delete('/:id', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
// 检查是否是唯一的默认模型
|
||||
if (model.isDefault) {
|
||||
const defaultCount = await AIModel.countDocuments({ isDefault: true })
|
||||
if (defaultCount <= 1) {
|
||||
return ApiResponse.error(res, '不能删除唯一的默认模型,请先设置其他默认模型', 400)
|
||||
}
|
||||
}
|
||||
|
||||
await AIModel.findByIdAndDelete(id)
|
||||
|
||||
logger.info('删除AI模型:', { id, name: model.name, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, { message: '删除成功' })
|
||||
|
||||
} catch (error) {
|
||||
logger.error('删除模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 设置默认模型
|
||||
* POST /api/ai-model/:id/set-default
|
||||
*/
|
||||
router.post('/:id/set-default', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
if (!model.isActive) {
|
||||
return ApiResponse.error(res, '不能将禁用模型设为默认', 400)
|
||||
}
|
||||
|
||||
model.isDefault = true
|
||||
await model.save()
|
||||
|
||||
logger.info('设置默认AI模型:', { id, name: model.name, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '设置默认模型成功',
|
||||
model: {
|
||||
_id: model._id,
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
isDefault: true
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('设置默认模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 初始化默认模型(从环境变量)
|
||||
* POST /api/ai-model/init-default
|
||||
*/
|
||||
router.post('/init-default', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
// 检查是否已有模型
|
||||
const existingCount = await AIModel.countDocuments()
|
||||
|
||||
if (existingCount > 0) {
|
||||
return ApiResponse.error(res, '已有模型配置,如需重新初始化请先删除现有模型', 400)
|
||||
}
|
||||
|
||||
// 从环境变量创建默认模型
|
||||
const defaultModel = new AIModel({
|
||||
name: '默认模型',
|
||||
modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo',
|
||||
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
|
||||
apiKey: process.env.OPENAI_API_KEY || '',
|
||||
description: '从环境变量初始化的默认模型',
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
priority: 0
|
||||
})
|
||||
|
||||
await defaultModel.save()
|
||||
|
||||
logger.info('初始化默认AI模型:', { admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '初始化默认模型成功',
|
||||
model: {
|
||||
_id: defaultModel._id,
|
||||
name: defaultModel.name,
|
||||
modelId: defaultModel.modelId,
|
||||
isDefault: true
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('初始化默认模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,492 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { User, Admin, AdminLog, LoginLog } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const weChatService = require('../utils/weixin')
|
||||
const logger = require('../utils/logger')
|
||||
const axios = require('axios')
|
||||
|
||||
/**
|
||||
* 获取客户端IP
|
||||
*/
|
||||
const getClientIp = (req) => {
|
||||
return req.headers['x-forwarded-for'] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析User-Agent
|
||||
*/
|
||||
const parseUserAgent = (userAgent) => {
|
||||
if (!userAgent) return { device: '', os: '', browser: '' }
|
||||
|
||||
const device = /Mobile|Android|iPhone|iPad|iPod/i.test(userAgent) ? 'mobile' : 'desktop'
|
||||
|
||||
let os = ''
|
||||
if (/Windows/i.test(userAgent)) os = 'Windows'
|
||||
else if (/Mac OS X/i.test(userAgent)) os = 'MacOS'
|
||||
else if (/Android/i.test(userAgent)) os = 'Android'
|
||||
else if (/iOS|iPhone|iPad|iPod/i.test(userAgent)) os = 'iOS'
|
||||
else if (/Linux/i.test(userAgent)) os = 'Linux'
|
||||
else os = 'Unknown'
|
||||
|
||||
let browser = ''
|
||||
if (/MicroMessenger/i.test(userAgent)) browser = 'WeChat'
|
||||
else if (/Chrome/i.test(userAgent)) browser = 'Chrome'
|
||||
else if (/Safari/i.test(userAgent)) browser = 'Safari'
|
||||
else if (/Firefox/i.test(userAgent)) browser = 'Firefox'
|
||||
else if (/Edge/i.test(userAgent)) browser = 'Edge'
|
||||
else browser = 'Unknown'
|
||||
|
||||
return { device, os, browser }
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录日志
|
||||
*/
|
||||
const recordLoginLog = async (userId, openid, type, method, source, req, success = true, failReason = '') => {
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const { device, os, browser } = parseUserAgent(userAgent)
|
||||
|
||||
await LoginLog.create({
|
||||
userId,
|
||||
openid,
|
||||
type,
|
||||
method,
|
||||
source,
|
||||
ip,
|
||||
userAgent,
|
||||
device,
|
||||
os,
|
||||
browser,
|
||||
success,
|
||||
failReason
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('记录登录日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序登录
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { code, userInfo, source = 'miniapp' } = req.body
|
||||
|
||||
// 方式1: 通过 code 登录(推荐)
|
||||
let openid = null
|
||||
if (code) {
|
||||
const sessionData = await weChatService.code2Session(code)
|
||||
openid = sessionData.openid
|
||||
}
|
||||
|
||||
// 方式2: 开发环境直接使用 openid (已废弃,仅用于本地调试)
|
||||
if (!openid && process.env.NODE_ENV === 'development' && req.body.openid) {
|
||||
openid = req.body.openid
|
||||
}
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
// 查找或创建用户
|
||||
let user = await User.findOne({ openid })
|
||||
let isNewUser = false
|
||||
|
||||
if (!user) {
|
||||
// 新用户
|
||||
user = new User({
|
||||
openid,
|
||||
nickName: userInfo?.nickName || '星辰旅行者',
|
||||
avatarUrl: userInfo?.avatarUrl || '',
|
||||
unlockedDims: [1]
|
||||
})
|
||||
isNewUser = true
|
||||
logger.info(`新用户注册: ${openid}`)
|
||||
} else {
|
||||
// 老用户,更新登录时间
|
||||
user.lastLoginAt = new Date()
|
||||
if (userInfo?.nickName) user.nickName = userInfo.nickName
|
||||
if (userInfo?.avatarUrl) user.avatarUrl = userInfo.avatarUrl
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
// 记录登录日志
|
||||
await recordLoginLog(
|
||||
user._id,
|
||||
openid,
|
||||
isNewUser ? 'register' : 'login',
|
||||
'wechat_miniapp',
|
||||
source,
|
||||
req,
|
||||
true
|
||||
)
|
||||
|
||||
// 生成 JWT token(可选,小程序通常不需要)
|
||||
const token = jwt.sign(
|
||||
{ openid, userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
openid,
|
||||
isNewUser,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
},
|
||||
firstPlayDate: user.createdAt
|
||||
}, isNewUser ? '注册成功' : '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* WebView 静默登录
|
||||
* POST /api/auth/webview-login
|
||||
* 用于微信小程序 webview 内嵌 H5 的自动登录
|
||||
*/
|
||||
router.post('/webview-login', async (req, res) => {
|
||||
try {
|
||||
const { code, userInfo } = req.body
|
||||
|
||||
if (!code) {
|
||||
return ApiResponse.error(res, '缺少登录凭证', 400)
|
||||
}
|
||||
|
||||
// 通过 code 获取 openid
|
||||
let openid = null
|
||||
try {
|
||||
const sessionData = await weChatService.code2Session(code)
|
||||
openid = sessionData.openid
|
||||
} catch (error) {
|
||||
logger.error('WebView登录获取session失败:', error)
|
||||
return ApiResponse.error(res, '登录凭证无效', 401)
|
||||
}
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
// 查找或创建用户
|
||||
let user = await User.findOne({ openid })
|
||||
let isNewUser = false
|
||||
|
||||
if (!user) {
|
||||
// 新用户 - 使用微信提供的信息创建账户
|
||||
user = new User({
|
||||
openid,
|
||||
nickName: userInfo?.nickName || '星辰旅行者',
|
||||
avatarUrl: userInfo?.avatarUrl || '',
|
||||
unlockedDims: [1]
|
||||
})
|
||||
isNewUser = true
|
||||
logger.info(`WebView新用户注册: ${openid}`)
|
||||
} else {
|
||||
// 老用户,更新登录时间和信息
|
||||
user.lastLoginAt = new Date()
|
||||
if (userInfo?.nickName) user.nickName = userInfo.nickName
|
||||
if (userInfo?.avatarUrl) user.avatarUrl = userInfo.avatarUrl
|
||||
logger.info(`WebView老用户登录: ${openid}`)
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
// 记录登录日志
|
||||
await recordLoginLog(
|
||||
user._id,
|
||||
openid,
|
||||
isNewUser ? 'register' : 'auto_login',
|
||||
'wechat_webview',
|
||||
'webview',
|
||||
req,
|
||||
true
|
||||
)
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ openid, userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
openid,
|
||||
isNewUser,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
},
|
||||
firstPlayDate: user.createdAt
|
||||
}, isNewUser ? '注册成功' : '自动登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('WebView登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
* POST /api/auth/admin/login
|
||||
*/
|
||||
router.post('/admin/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
|
||||
// 查找管理员
|
||||
const admin = await Admin.findOne({ username })
|
||||
if (!admin) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isMatch = await admin.validatePassword(password)
|
||||
if (!isMatch) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (admin.status !== 'active') {
|
||||
return ApiResponse.error(res, '账户已禁用', 403)
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
admin.lastLogin = new Date()
|
||||
admin.lastLoginIp = req.ip
|
||||
admin.loginCount += 1
|
||||
await admin.save()
|
||||
|
||||
// 记录日志
|
||||
await AdminLog.create({
|
||||
adminId: admin._id,
|
||||
adminName: admin.username,
|
||||
action: 'login',
|
||||
resource: 'system',
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
})
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ adminId: admin._id, role: admin.role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
// 获取权限
|
||||
const permissions = Admin.getPermissionsByRole(admin.role)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
adminId: admin._id,
|
||||
username: admin.username,
|
||||
role: admin.role,
|
||||
permissions
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('管理员登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* H5环境用户名密码登录
|
||||
* POST /api/auth/user/login
|
||||
*/
|
||||
router.post('/user/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
|
||||
// 查找用户(显式包含密码字段)
|
||||
const user = await User.findOne({ username }).select('+password')
|
||||
if (!user) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isMatch = await user.validatePassword(password)
|
||||
if (!isMatch) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 更新登录时间
|
||||
user.lastLoginAt = new Date()
|
||||
await user.save()
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ userId: user._id, openid: user.openid },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
userId: user._id,
|
||||
openid: user.openid,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
},
|
||||
firstPlayDate: user.createdAt
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('用户登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* H5环境用户注册
|
||||
* POST /api/auth/user/register
|
||||
*/
|
||||
router.post('/user/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password, nickName, avatarUrl } = req.body
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await User.findOne({ username })
|
||||
if (existingUser) {
|
||||
return ApiResponse.error(res, '用户名已存在', 400)
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
const user = new User({
|
||||
username,
|
||||
password,
|
||||
nickName: nickName || '星辰旅行者',
|
||||
avatarUrl: avatarUrl || '',
|
||||
unlockedDims: [1]
|
||||
})
|
||||
await user.save()
|
||||
|
||||
logger.info(`新用户注册: ${username}`)
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
userId: user._id,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
}
|
||||
}, '注册成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('用户注册失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
router.get('/me', async (req, res) => {
|
||||
try {
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
return ApiResponse.unauthorized(res)
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
const user = await User.findById(decoded.userId)
|
||||
|
||||
if (!user) {
|
||||
return ApiResponse.notFound(res, '用户不存在')
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, user)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取用户信息失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* WebView Token 交换接口
|
||||
* POST /api/auth/webview-token
|
||||
* 用于小程序 WebView 内嵌 H5 时,通过 openid 获取登录 token
|
||||
* 小程序在 URL 中传递 openid,H5 用这个接口换取 token
|
||||
*/
|
||||
router.post('/webview-token', async (req, res) => {
|
||||
try {
|
||||
const { openid } = req.body
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '缺少用户标识', 400)
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const user = await User.findOne({ openid })
|
||||
|
||||
if (!user) {
|
||||
return ApiResponse.error(res, '用户不存在', 404)
|
||||
}
|
||||
|
||||
// 生成新的 JWT token
|
||||
const token = jwt.sign(
|
||||
{ openid, userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
// 更新最后登录时间
|
||||
user.lastLoginAt = new Date()
|
||||
await user.save()
|
||||
|
||||
// 记录登录日志
|
||||
await recordLoginLog(
|
||||
user._id,
|
||||
openid,
|
||||
'webview_auto_login',
|
||||
'wechat_webview',
|
||||
'webview_h5',
|
||||
req,
|
||||
true
|
||||
)
|
||||
|
||||
logger.info(`WebView Token交换成功: ${openid}`)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
openid,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
}
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('WebView Token交换失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,201 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { BGM } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { authMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取指定维度的随机BGM(公开接口,移动端调用)
|
||||
* GET /api/bgm/random/:dimension
|
||||
*/
|
||||
router.get('/random/:dimension', async (req, res) => {
|
||||
try {
|
||||
const dimension = parseInt(req.params.dimension)
|
||||
|
||||
if (![1, 2, 3, 4, 5].includes(dimension)) {
|
||||
return ApiResponse.badRequest(res, '无效的维度参数')
|
||||
}
|
||||
|
||||
// 查询该维度下所有激活的BGM
|
||||
const bgms = await BGM.find({
|
||||
dimension,
|
||||
isActive: true
|
||||
}).sort({ sortOrder: 1 })
|
||||
|
||||
if (!bgms || bgms.length === 0) {
|
||||
return ApiResponse.success(res, null)
|
||||
}
|
||||
|
||||
// 随机选择一个
|
||||
const randomIndex = Math.floor(Math.random() * bgms.length)
|
||||
const selectedBGM = bgms[randomIndex]
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
id: selectedBGM._id,
|
||||
name: selectedBGM.name,
|
||||
url: selectedBGM.url,
|
||||
duration: selectedBGM.duration,
|
||||
loop: selectedBGM.loop,
|
||||
volume: selectedBGM.volume,
|
||||
dimension: selectedBGM.dimension
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取随机BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取所有BGM列表(管理员)
|
||||
* GET /api/admin/bgm/list
|
||||
*/
|
||||
router.get('/list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { dimension, page = 1, limit = 20 } = req.query
|
||||
|
||||
const query = {}
|
||||
if (dimension && [1, 2, 3, 4, 5].includes(parseInt(dimension))) {
|
||||
query.dimension = parseInt(dimension)
|
||||
}
|
||||
|
||||
const pageNum = parseInt(page)
|
||||
const limitNum = parseInt(limit)
|
||||
const skip = (pageNum - 1) * limitNum
|
||||
|
||||
const [bgms, total] = await Promise.all([
|
||||
BGM.find(query)
|
||||
.sort({ dimension: 1, sortOrder: 1, createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limitNum),
|
||||
BGM.countDocuments(query)
|
||||
])
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: bgms,
|
||||
total,
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取BGM列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建BGM(管理员)
|
||||
* POST /api/admin/bgm/create
|
||||
*/
|
||||
router.post('/create', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { name, dimension, url, duration, loop, volume, sortOrder, description } = req.body
|
||||
|
||||
if (!name || !dimension || !url) {
|
||||
return ApiResponse.badRequest(res, '名称、维度和URL为必填项')
|
||||
}
|
||||
|
||||
if (![1, 2, 3, 4, 5].includes(parseInt(dimension))) {
|
||||
return ApiResponse.badRequest(res, '无效的维度参数')
|
||||
}
|
||||
|
||||
const bgm = new BGM({
|
||||
name,
|
||||
dimension: parseInt(dimension),
|
||||
url,
|
||||
duration: duration || 0,
|
||||
loop: loop !== undefined ? loop : true,
|
||||
volume: volume !== undefined ? volume : 0.5,
|
||||
sortOrder: sortOrder || 0,
|
||||
description: description || ''
|
||||
})
|
||||
|
||||
await bgm.save()
|
||||
|
||||
logger.info(`管理员创建了BGM: ${name} (维度${dimension})`)
|
||||
return ApiResponse.success(res, { message: 'BGM创建成功', data: bgm })
|
||||
} catch (error) {
|
||||
logger.error('创建BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新BGM(管理员)
|
||||
* PUT /api/admin/bgm/update/:id
|
||||
*/
|
||||
router.put('/update/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const updates = req.body
|
||||
|
||||
const bgm = await BGM.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: updates },
|
||||
{ new: true, runValidators: true }
|
||||
)
|
||||
|
||||
if (!bgm) {
|
||||
return ApiResponse.notFound(res, 'BGM不存在')
|
||||
}
|
||||
|
||||
logger.info(`管理员更新了BGM: ${bgm.name}`)
|
||||
return ApiResponse.success(res, { message: 'BGM更新成功', data: bgm })
|
||||
} catch (error) {
|
||||
logger.error('更新BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除BGM(管理员)
|
||||
* DELETE /api/admin/bgm/delete/:id
|
||||
*/
|
||||
router.delete('/delete/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const bgm = await BGM.findByIdAndDelete(id)
|
||||
|
||||
if (!bgm) {
|
||||
return ApiResponse.notFound(res, 'BGM不存在')
|
||||
}
|
||||
|
||||
logger.info(`管理员删除了BGM: ${bgm.name}`)
|
||||
return ApiResponse.success(res, { message: 'BGM删除成功' })
|
||||
} catch (error) {
|
||||
logger.error('删除BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 切换BGM激活状态(管理员)
|
||||
* PUT /api/admin/bgm/toggle/:id
|
||||
*/
|
||||
router.put('/toggle/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const bgm = await BGM.findById(id)
|
||||
|
||||
if (!bgm) {
|
||||
return ApiResponse.notFound(res, 'BGM不存在')
|
||||
}
|
||||
|
||||
bgm.isActive = !bgm.isActive
|
||||
await bgm.save()
|
||||
|
||||
logger.info(`管理员${bgm.isActive ? '激活' : '禁用'}了BGM: ${bgm.name}`)
|
||||
return ApiResponse.success(res, {
|
||||
message: `BGM已${bgm.isActive ? '激活' : '禁用'}`,
|
||||
data: bgm
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('切换BGM状态失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,228 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { Feedback, User } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取客户端IP
|
||||
*/
|
||||
const getClientIp = (req) => {
|
||||
return req.headers['x-forwarded-for'] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交反馈
|
||||
* POST /api/feedback
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
images,
|
||||
relatedPage,
|
||||
deviceInfo,
|
||||
userContact
|
||||
} = req.body
|
||||
|
||||
// 验证必填字段
|
||||
if (!type || !title || !content) {
|
||||
return ApiResponse.error(res, '请填写完整的反馈信息', 400)
|
||||
}
|
||||
|
||||
// 获取用户信息(如果已登录)
|
||||
let userId = null
|
||||
let openid = null
|
||||
let userName = ''
|
||||
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '')
|
||||
if (token) {
|
||||
const jwt = require('jsonwebtoken')
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
userId = decoded.userId
|
||||
openid = decoded.openid
|
||||
|
||||
// 获取用户信息
|
||||
const user = await User.findById(userId)
|
||||
if (user) {
|
||||
userName = user.nickName || ''
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Token 验证失败,继续以匿名方式提交
|
||||
logger.warn('反馈提交时Token验证失败:', error.message)
|
||||
}
|
||||
|
||||
// 创建反馈记录
|
||||
const feedback = new Feedback({
|
||||
userId,
|
||||
openid,
|
||||
userName,
|
||||
userContact: userContact || '',
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
images: images || [],
|
||||
relatedPage: relatedPage || '',
|
||||
deviceInfo: deviceInfo || {},
|
||||
networkInfo: {
|
||||
ip: getClientIp(req)
|
||||
},
|
||||
status: 'pending',
|
||||
priority: 'normal'
|
||||
})
|
||||
|
||||
await feedback.save()
|
||||
|
||||
logger.info(`新反馈提交: ${feedback._id}, 类型: ${type}`)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
feedbackId: feedback._id
|
||||
}, '反馈提交成功,我们会尽快处理')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('提交反馈失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取我的反馈列表
|
||||
* GET /api/feedback/my
|
||||
*/
|
||||
router.get('/my', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { page = 1, limit = 20 } = req.query
|
||||
|
||||
const query = { userId: user._id }
|
||||
|
||||
const total = await Feedback.countDocuments(query)
|
||||
const feedbacks = await Feedback.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||||
.limit(parseInt(limit))
|
||||
.select('-deviceInfo -networkInfo')
|
||||
.lean()
|
||||
|
||||
// 格式化返回数据
|
||||
const formattedFeedbacks = feedbacks.map(fb => ({
|
||||
id: fb._id,
|
||||
type: fb.type,
|
||||
title: fb.title,
|
||||
content: fb.content.substring(0, 100) + (fb.content.length > 100 ? '...' : ''),
|
||||
images: fb.images,
|
||||
status: fb.status,
|
||||
priority: fb.priority,
|
||||
result: fb.result,
|
||||
createdAt: fb.createdAt,
|
||||
updatedAt: fb.updatedAt
|
||||
}))
|
||||
|
||||
return ApiResponse.paginated(res, formattedFeedbacks, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取我的反馈失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取反馈详情
|
||||
* GET /api/feedback/:id
|
||||
*/
|
||||
router.get('/:id', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const feedback = await Feedback.findById(req.params.id).lean()
|
||||
|
||||
if (!feedback) {
|
||||
return ApiResponse.notFound(res, '反馈不存在')
|
||||
}
|
||||
|
||||
// 检查权限(只能查看自己的反馈)
|
||||
if (feedback.userId?.toString() !== user._id.toString()) {
|
||||
return ApiResponse.error(res, '无权查看此反馈', 403)
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
id: feedback._id,
|
||||
type: feedback.type,
|
||||
title: feedback.title,
|
||||
content: feedback.content,
|
||||
images: feedback.images,
|
||||
relatedPage: feedback.relatedPage,
|
||||
status: feedback.status,
|
||||
priority: feedback.priority,
|
||||
processLog: feedback.processLog,
|
||||
result: feedback.result,
|
||||
handler: feedback.handler,
|
||||
handledAt: feedback.handledAt,
|
||||
rating: feedback.rating,
|
||||
createdAt: feedback.createdAt,
|
||||
updatedAt: feedback.updatedAt
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取反馈详情失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 对处理结果评分
|
||||
* POST /api/feedback/:id/rate
|
||||
*/
|
||||
router.post('/:id/rate', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { rating, remark } = req.body
|
||||
|
||||
if (!rating || rating < 1 || rating > 5) {
|
||||
return ApiResponse.error(res, '评分必须在1-5之间', 400)
|
||||
}
|
||||
|
||||
const feedback = await Feedback.findById(req.params.id)
|
||||
|
||||
if (!feedback) {
|
||||
return ApiResponse.notFound(res, '反馈不存在')
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (feedback.userId?.toString() !== user._id.toString()) {
|
||||
return ApiResponse.error(res, '无权操作此反馈', 403)
|
||||
}
|
||||
|
||||
// 只能对已解决的反馈评分
|
||||
if (feedback.status !== 'resolved' && feedback.status !== 'closed') {
|
||||
return ApiResponse.error(res, '只能对已处理的反馈进行评分', 400)
|
||||
}
|
||||
|
||||
feedback.rating = rating
|
||||
if (remark) {
|
||||
feedback.userRemark = remark
|
||||
}
|
||||
|
||||
await feedback.save()
|
||||
|
||||
return ApiResponse.success(res, {}, '评分成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('反馈评分失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,209 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { Gallery } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// 应用用户认证中间件到需要登录的路由
|
||||
// 注意:POST /api/gallery 需要认证
|
||||
router.post('/', userAuthMiddleware)
|
||||
router.use('/like', userAuthMiddleware)
|
||||
// 作品列表接口使用可选认证,这样未登录用户也能访问
|
||||
router.get('/works', optionalAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 获取作品列表(兼容旧版API)
|
||||
* GET /api/gallery/works
|
||||
*/
|
||||
router.get('/works', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 12, filter = 'all' } = req.query
|
||||
|
||||
const query = { status: 'approved' }
|
||||
|
||||
// 处理筛选条件
|
||||
if (filter === 'dim2') {
|
||||
query.dim = 2
|
||||
} else if (filter === 'dim3') {
|
||||
query.dim = 3
|
||||
} else if (filter === 'my') {
|
||||
// 显示当前用户的作品,需要认证
|
||||
if (!req.user) {
|
||||
return ApiResponse.error(res, '需要登录才能查看我的作品', 401)
|
||||
}
|
||||
query.openid = req.user.openid
|
||||
}
|
||||
|
||||
const total = await Gallery.countDocuments(query)
|
||||
const works = await Gallery.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
// 格式化返回数据
|
||||
const formattedWorks = works.map(work => ({
|
||||
_id: work._id,
|
||||
fileURL: work.imageUrl || work.imageData,
|
||||
authorAvatar: work.authorAvatar || '/static/images/default_avatar.png',
|
||||
authorName: work.authorName || '星辰旅者',
|
||||
likes: work.likeCount || 0,
|
||||
views: work.viewCount || 0,
|
||||
dim: work.dim || 2,
|
||||
type: work.dim === 2 ? 'dim2' : 'dim3',
|
||||
description: work.description || '',
|
||||
createdAt: work.createdAt
|
||||
}))
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: formattedWorks,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取作品列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取作品列表
|
||||
* GET /api/gallery
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, status = 'approved', dim } = req.query
|
||||
|
||||
const query = { status }
|
||||
if (dim) query.dim = parseInt(dim)
|
||||
|
||||
const total = await Gallery.countDocuments(query)
|
||||
const works = await Gallery.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
return ApiResponse.paginated(res, works, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取作品列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 上传作品
|
||||
* POST /api/gallery
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { title, description, imageData, imageUrl, tags, dim } = req.body
|
||||
|
||||
if (!title || !dim) {
|
||||
return ApiResponse.error(res, '标题和维度为必填项', 400)
|
||||
}
|
||||
|
||||
const work = new Gallery({
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
authorName: user.nickName,
|
||||
authorAvatar: user.avatarUrl,
|
||||
title,
|
||||
description,
|
||||
imageData,
|
||||
imageUrl,
|
||||
tags: tags || [],
|
||||
dim,
|
||||
status: 'approved' // 自动审核通过,用户可以立即看到自己的作品
|
||||
})
|
||||
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, work, '作品上传成功,等待审核')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('上传作品失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取作品详情
|
||||
* GET /api/gallery/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const work = await Gallery.findById(req.params.id)
|
||||
|
||||
if (!work) {
|
||||
return ApiResponse.notFound(res, '作品不存在')
|
||||
}
|
||||
|
||||
// 增加浏览量
|
||||
work.viewCount += 1
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, work)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取作品详情失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 点赞作品(兼容移动端API)
|
||||
* POST /api/gallery/like/:id
|
||||
*/
|
||||
router.post('/like/:id', async (req, res) => {
|
||||
try {
|
||||
const work = await Gallery.findById(req.params.id)
|
||||
|
||||
if (!work) {
|
||||
return ApiResponse.notFound(res, '作品不存在')
|
||||
}
|
||||
|
||||
work.likeCount += 1
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, { likeCount: work.likeCount }, '点赞成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('点赞失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 点赞作品
|
||||
* POST /api/gallery/:id/like
|
||||
*/
|
||||
router.post('/:id/like', async (req, res) => {
|
||||
try {
|
||||
const work = await Gallery.findById(req.params.id)
|
||||
|
||||
if (!work) {
|
||||
return ApiResponse.notFound(res, '作品不存在')
|
||||
}
|
||||
|
||||
work.likeCount += 1
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, { likeCount: work.likeCount }, '点赞成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('点赞失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,57 @@
|
||||
const authRoutes = require('./auth')
|
||||
const userRoutes = require('./user')
|
||||
const paymentRoutes = require('./payment')
|
||||
const galleryRoutes = require('./gallery')
|
||||
const knowledgeRoutes = require('./knowledge')
|
||||
const adminRoutes = require('./admin')
|
||||
const shareRoutes = require('./share')
|
||||
const bgmRoutes = require('./bgm')
|
||||
const aiChatRoutes = require('./aiChat')
|
||||
const aiModelRoutes = require('./aiModel')
|
||||
const feedbackRoutes = require('./feedback')
|
||||
const pinyinRoutes = require('./pinyin')
|
||||
const trendRoutes = require('./trend')
|
||||
|
||||
module.exports = (app) => {
|
||||
// 认证路由
|
||||
app.use('/api/auth', authRoutes)
|
||||
|
||||
// 用户路由
|
||||
app.use('/api/user', userRoutes)
|
||||
|
||||
// 支付路由
|
||||
app.use('/api/payment', paymentRoutes)
|
||||
|
||||
// 画廊路由
|
||||
app.use('/api/gallery', galleryRoutes)
|
||||
|
||||
// 知识库路由
|
||||
app.use('/api/knowledge', knowledgeRoutes)
|
||||
|
||||
// 反馈路由
|
||||
app.use('/api/feedback', feedbackRoutes)
|
||||
|
||||
// 管理后台路由
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
// 分享记录路由
|
||||
app.use('/api/share', shareRoutes)
|
||||
|
||||
// BGM音效路由(公开接口 + 管理员接口)
|
||||
app.use('/api/bgm', bgmRoutes)
|
||||
|
||||
// AI问答路由
|
||||
app.use('/api/ai-chat', aiChatRoutes)
|
||||
|
||||
// AI模型配置路由
|
||||
app.use('/api/ai-model', aiModelRoutes)
|
||||
|
||||
// 拼音探索路由
|
||||
app.use('/api/pinyin/contents', pinyinRoutes.contents)
|
||||
app.use('/api/pinyin/progress', pinyinRoutes.progress)
|
||||
app.use('/api/pinyin/games', pinyinRoutes.games)
|
||||
app.use('/api/pinyin/achievements', pinyinRoutes.achievements)
|
||||
|
||||
// AI趋势路由
|
||||
app.use('/api/trend', trendRoutes)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { Knowledge } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取知识列表
|
||||
* GET /api/knowledge
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, dim, category, status = 'approved' } = req.query
|
||||
|
||||
const query = { status }
|
||||
if (dim) query.dim = parseInt(dim)
|
||||
if (category) query.category = category
|
||||
|
||||
const total = await Knowledge.countDocuments(query)
|
||||
const knowledge = await Knowledge.find(query)
|
||||
.sort({ sortOrder: 1, createdAt: -1 })
|
||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
return ApiResponse.paginated(res, knowledge, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取知识列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户收藏的知识列表
|
||||
* GET /api/knowledge/collected
|
||||
*/
|
||||
router.get('/collected', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
// 查找用户收藏的知识
|
||||
const collectedKnowledge = await Knowledge.find({ _id: { $in: user.collectedKnowledge } })
|
||||
.lean()
|
||||
|
||||
// 格式化返回数据
|
||||
const formattedKnowledge = collectedKnowledge.map(item => ({
|
||||
id: item._id,
|
||||
dimension: item.dim,
|
||||
title: item.title,
|
||||
description: item.content || item.description || '',
|
||||
knowledgeId: item._id
|
||||
}))
|
||||
|
||||
return ApiResponse.success(res, formattedKnowledge)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取收藏列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除收藏
|
||||
* DELETE /api/knowledge/collect/:id
|
||||
*/
|
||||
router.delete('/collect/:id', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const knowledgeId = req.params.id
|
||||
const user = req.user
|
||||
|
||||
// 检查是否已经收藏
|
||||
if (!user.collectedKnowledge.includes(knowledgeId)) {
|
||||
return ApiResponse.error(res, '未收藏该知识', 400)
|
||||
}
|
||||
|
||||
// 从用户的 collectedKnowledge 数组中移除
|
||||
user.collectedKnowledge = user.collectedKnowledge.filter(id => id.toString() !== knowledgeId.toString())
|
||||
await user.save()
|
||||
|
||||
// 减少收藏计数
|
||||
const knowledge = await Knowledge.findById(knowledgeId)
|
||||
if (knowledge) {
|
||||
knowledge.collectionCount = Math.max(0, knowledge.collectionCount - 1)
|
||||
await knowledge.save()
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, {}, '删除收藏成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('删除收藏失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取知识详情
|
||||
* GET /api/knowledge/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const knowledge = await Knowledge.findById(req.params.id)
|
||||
|
||||
if (!knowledge) {
|
||||
return ApiResponse.notFound(res, '知识不存在')
|
||||
}
|
||||
|
||||
// 增加浏览量
|
||||
knowledge.viewCount += 1
|
||||
await knowledge.save()
|
||||
|
||||
return ApiResponse.success(res, knowledge)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取知识详情失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 收藏知识
|
||||
* POST /api/knowledge/:id/collect
|
||||
*/
|
||||
router.post('/:id/collect', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const knowledge = await Knowledge.findById(req.params.id)
|
||||
|
||||
if (!knowledge) {
|
||||
return ApiResponse.notFound(res, '知识不存在')
|
||||
}
|
||||
|
||||
const user = req.user
|
||||
|
||||
// 检查是否已经收藏
|
||||
if (user.collectedKnowledge.includes(knowledge._id)) {
|
||||
return ApiResponse.error(res, '已经收藏过了', 400)
|
||||
}
|
||||
|
||||
// 将知识ID加入用户的 collectedKnowledge 数组
|
||||
user.collectedKnowledge.push(knowledge._id)
|
||||
await user.save()
|
||||
|
||||
// 增加收藏计数
|
||||
knowledge.collectionCount += 1
|
||||
await knowledge.save()
|
||||
|
||||
return ApiResponse.success(res, { collectionCount: knowledge.collectionCount }, '收藏成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('收藏失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取问答
|
||||
* GET /api/knowledge/:dim/questions
|
||||
*/
|
||||
router.get('/:dim/questions', async (req, res) => {
|
||||
try {
|
||||
const { dim } = req.params
|
||||
const { limit = 10 } = req.query
|
||||
|
||||
const questions = await Knowledge.find({
|
||||
dim: parseInt(dim),
|
||||
category: 'question',
|
||||
status: 'approved'
|
||||
})
|
||||
.select('question options')
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
return ApiResponse.success(res, questions)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取问答失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,266 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { User, Order, ShopItem } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const weChatService = require('../utils/weixin')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取商品列表(从数据库)- 公开接口
|
||||
* GET /api/payment/products
|
||||
*/
|
||||
router.get('/products', async (req, res) => {
|
||||
try {
|
||||
const items = await ShopItem.find({ status: 'active' }).sort({ sort: 1 })
|
||||
return ApiResponse.success(res, items)
|
||||
} catch (error) {
|
||||
logger.error('获取商品列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 应用用户认证中间件到需要保护的路由
|
||||
router.use(userAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
* POST /api/payment/create-order
|
||||
*/
|
||||
router.post('/create-order', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId } = req.body
|
||||
|
||||
// 从数据库获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, '无效商品 ID 或商品已下架', 400)
|
||||
}
|
||||
|
||||
// 检查是否已购买(针对皮肤类一次性商品)
|
||||
if (product.type === 'skin' && user.ownedSkins?.includes(productId)) {
|
||||
return ApiResponse.error(res, '已拥有该商品', 400)
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
const orderNo = weChatService.generateOrderNo('WD')
|
||||
|
||||
// 创建订单记录
|
||||
const order = new Order({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
status: 'pending'
|
||||
})
|
||||
await order.save()
|
||||
|
||||
// 调用微信支付统一下单
|
||||
const payParams = await weChatService.createPaymentOrder({
|
||||
openid: user.openid,
|
||||
orderNo,
|
||||
body: `宇之然-${product.name}`,
|
||||
amount: product.price
|
||||
})
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
orderNo,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
payParams
|
||||
}, '订单创建成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('创建订单失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 支付回调
|
||||
* POST /api/payment/callback
|
||||
*/
|
||||
router.post('/callback', async (req, res) => {
|
||||
try {
|
||||
const data = req.body
|
||||
|
||||
// 验证签名
|
||||
if (!weChatService.verifyCallbackSignature(data, data.sign)) {
|
||||
logger.warn('支付签名验证失败')
|
||||
return res.status(400).send('FAIL')
|
||||
}
|
||||
|
||||
const orderNo = data.out_trade_no
|
||||
const transactionId = data.transaction_id
|
||||
|
||||
// 查询订单
|
||||
const order = await Order.findOne({ orderNo })
|
||||
if (!order) {
|
||||
logger.error(`订单不存在: ${orderNo}`)
|
||||
return res.status(400).send('FAIL')
|
||||
}
|
||||
|
||||
// 已处理过
|
||||
if (order.status === 'paid' || order.status === 'completed') {
|
||||
return res.status(200).send('SUCCESS')
|
||||
}
|
||||
|
||||
// 更新订单状态
|
||||
order.status = 'paid'
|
||||
order.transactionId = transactionId
|
||||
order.paidAt = new Date()
|
||||
await order.save()
|
||||
|
||||
// 解锁商品
|
||||
const user = await User.findOne({ openid: order.openid })
|
||||
if (user) {
|
||||
const product = await ShopItem.findOne({ itemId: order.productId })
|
||||
|
||||
if (product) {
|
||||
if (product.type === 'skin') {
|
||||
if (!user.ownedSkins.includes(order.productId)) {
|
||||
user.ownedSkins.push(order.productId)
|
||||
}
|
||||
} else if (product.type === 'noad') {
|
||||
user.isAdFree = true
|
||||
} else if (product.type === 'subscription') {
|
||||
const expiry = new Date()
|
||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||
user.isSubscriber = true
|
||||
user.subscribeExpiry = expiry
|
||||
}
|
||||
await user.save()
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`支付成功: ${orderNo}`)
|
||||
return res.status(200).send('SUCCESS')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('支付回调处理失败:', error)
|
||||
return res.status(500).send('FAIL')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 验证支付结果
|
||||
* POST /api/payment/verify
|
||||
*/
|
||||
router.post('/verify', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId, orderNo } = req.body
|
||||
|
||||
// 验证订单是否已处理
|
||||
const order = await Order.findOne({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
status: { $in: ['paid', 'completed'] }
|
||||
})
|
||||
|
||||
if (order) {
|
||||
return ApiResponse.success(res, {
|
||||
alreadyProcessed: true,
|
||||
message: '订单已处理'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, `未知商品: ${productId}`, 400)
|
||||
}
|
||||
|
||||
// 更新用户商品
|
||||
let updateData = {}
|
||||
|
||||
if (product.type === 'skin') {
|
||||
if (!user.ownedSkins.includes(productId)) {
|
||||
user.ownedSkins.push(productId)
|
||||
}
|
||||
} else if (product.type === 'noad') {
|
||||
user.isAdFree = true
|
||||
} else if (product.type === 'subscription') {
|
||||
const expiry = new Date()
|
||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||
user.isSubscriber = true
|
||||
user.subscribeExpiry = expiry
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
// 记录订单
|
||||
const newOrder = new Order({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
status: 'completed',
|
||||
completedAt: new Date()
|
||||
})
|
||||
await newOrder.save()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
product,
|
||||
message: `${product.name} 已解锁!`
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('验证支付失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
* POST /api/payment/refund
|
||||
*/
|
||||
router.post('/refund', async (req, res) => {
|
||||
try {
|
||||
const { orderNo, reason } = req.body
|
||||
|
||||
// 查询订单
|
||||
const order = await Order.findOne({ orderNo })
|
||||
if (!order) {
|
||||
return ApiResponse.error(res, '订单不存在', 404)
|
||||
}
|
||||
|
||||
if (order.status !== 'paid' && order.status !== 'completed') {
|
||||
return ApiResponse.error(res, '订单状态不支持退款', 400)
|
||||
}
|
||||
|
||||
// 调用微信退款接口
|
||||
const result = await weChatService.refundOrder(
|
||||
order.transactionId,
|
||||
`REF_${order.orderNo}`,
|
||||
order.amount / 100,
|
||||
order.amount / 100,
|
||||
reason
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
// 更新订单状态
|
||||
order.status = 'refunded'
|
||||
order.refundReason = reason
|
||||
order.refundedAt = new Date()
|
||||
await order.save()
|
||||
|
||||
return ApiResponse.success(res, result, '退款申请已提交')
|
||||
} else {
|
||||
return ApiResponse.error(res, '退款申请失败', 500)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('申请退款失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware } = require('../../middleware/auth');
|
||||
const achievementController = require('../../controllers/pinyin/achievementController');
|
||||
|
||||
// 获取成就列表(公开)
|
||||
router.get('/', achievementController.getAchievements);
|
||||
|
||||
// 获取我的成就(需要登录)
|
||||
router.get('/my', authMiddleware, achievementController.getMyAchievements);
|
||||
|
||||
// 检查成就达成(需要登录)
|
||||
router.post('/check', authMiddleware, achievementController.checkAchievements);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware } = require('../../middleware/auth');
|
||||
const contentController = require('../../controllers/pinyin/contentController');
|
||||
|
||||
// 公开接口
|
||||
router.get('/', contentController.getContents);
|
||||
router.get('/:symbol', contentController.getContentBySymbol);
|
||||
router.get('/:symbol/audio', contentController.getContentAudio);
|
||||
|
||||
// 管理员接口
|
||||
router.post('/', authMiddleware, contentController.createContent);
|
||||
router.put('/:id', authMiddleware, contentController.updateContent);
|
||||
router.delete('/:id', authMiddleware, contentController.deleteContent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,24 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { userAuthMiddleware } = require('../../middleware/auth');
|
||||
const gameController = require('../../controllers/pinyin/gameController');
|
||||
|
||||
// 获取游戏配置
|
||||
router.get('/config', gameController.getGameConfig);
|
||||
|
||||
// 获取游戏配置(按类型)
|
||||
router.get('/config/:type', gameController.getGameConfigByType);
|
||||
|
||||
// 获取用户游戏统计(需要登录)
|
||||
router.get('/stats', userAuthMiddleware, gameController.getGameStats);
|
||||
|
||||
// 获取用户游戏记录(需要登录)
|
||||
router.get('/records', userAuthMiddleware, gameController.getGameRecords);
|
||||
|
||||
// 记录游戏结果(需要登录)
|
||||
router.post('/record', userAuthMiddleware, gameController.recordGame);
|
||||
|
||||
// 获取游戏排行榜
|
||||
router.get('/leaderboard/:type', gameController.getLeaderboard);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 拼音探索模块路由导出
|
||||
*/
|
||||
const contentsRouter = require('./contents');
|
||||
const progressRouter = require('./progress');
|
||||
const gamesRouter = require('./games');
|
||||
const achievementsRouter = require('./achievements');
|
||||
|
||||
module.exports = {
|
||||
contents: contentsRouter,
|
||||
progress: progressRouter,
|
||||
games: gamesRouter,
|
||||
achievements: achievementsRouter
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { userAuthMiddleware } = require('../../middleware/auth');
|
||||
const progressController = require('../../controllers/pinyin/progressController');
|
||||
|
||||
// 获取用户探索进度
|
||||
router.get('/', userAuthMiddleware, progressController.getProgress);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,67 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { ShareRecord, User } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware, optionalAuthMiddleware, authMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 记录分享行为(支持匿名)
|
||||
* POST /api/share/record
|
||||
*/
|
||||
router.post('/record', optionalAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { shareType, score } = req.body
|
||||
const openid = req.user ? req.user.openid : 'anonymous'
|
||||
const userId = req.user ? req.user._id : null
|
||||
|
||||
const record = new ShareRecord({
|
||||
openid,
|
||||
userId,
|
||||
shareType: shareType || 'app',
|
||||
score: score || 0
|
||||
})
|
||||
await record.save()
|
||||
|
||||
return ApiResponse.success(res, { message: '分享已记录' })
|
||||
} catch (error) {
|
||||
logger.error('记录分享失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取分享记录列表(管理员)
|
||||
* GET /api/share/list
|
||||
*/
|
||||
router.get('/list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, openid } = req.query
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const query = {}
|
||||
if (openid && openid !== 'all') {
|
||||
query.openid = openid
|
||||
}
|
||||
|
||||
const records = await ShareRecord.find(query)
|
||||
.sort({ sharedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.populate('userId', 'nickname avatar')
|
||||
|
||||
const total = await ShareRecord.countDocuments(query)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: records,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取分享记录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,83 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Trend = require('../models/Trend');
|
||||
|
||||
/**
|
||||
* 获取趋势列表
|
||||
* GET /api/trend?category=产品&page=1&limit=20
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { category, page = 1, limit = 20 } = req.query;
|
||||
const query = { status: 'published' };
|
||||
if (category) query.category = category;
|
||||
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
const [list, total] = await Promise.all([
|
||||
Trend.find(query)
|
||||
.sort({ newsDate: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.select('-__v'),
|
||||
Trend.countDocuments(query)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { list, total, page: parseInt(page), totalPages: Math.ceil(total / parseInt(limit)) }
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取趋势详情
|
||||
* GET /api/trend/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const trend = await Trend.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
{ $inc: { viewCount: 1 } },
|
||||
{ new: true }
|
||||
);
|
||||
if (!trend) return res.status(404).json({ success: false, error: '趋势不存在' });
|
||||
res.json({ success: true, data: trend });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 管理员:创建趋势
|
||||
* POST /api/trend/admin
|
||||
*/
|
||||
router.post('/admin', async (req, res) => {
|
||||
try {
|
||||
const trend = new Trend(req.body);
|
||||
await trend.save();
|
||||
res.json({ success: true, data: trend });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 管理员:批量创建趋势(给 cron 用)
|
||||
* POST /api/trend/admin/batch
|
||||
*/
|
||||
router.post('/admin/batch', async (req, res) => {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, error: 'items 不能为空' });
|
||||
}
|
||||
const result = await Trend.insertMany(items);
|
||||
res.json({ success: true, data: { inserted: result.length } });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,424 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { User, Order, Gallery } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// 应用用户认证中间件到所有路由
|
||||
router.use(userAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 获取用户进度
|
||||
* GET /api/user/progress
|
||||
*/
|
||||
router.get('/progress', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
if (!user) {
|
||||
// 新用户,返回默认进度
|
||||
return ApiResponse.success(res, {
|
||||
isNewUser: true,
|
||||
data: {
|
||||
unlockedDims: [1],
|
||||
dim1Score: 0,
|
||||
dim2Score: 0,
|
||||
dim3Score: 0,
|
||||
dim4Score: 0,
|
||||
dim5Score: 0,
|
||||
totalScore: 0,
|
||||
ownedSkins: [],
|
||||
isAdFree: false,
|
||||
isSubscriber: false,
|
||||
subscribeExpiry: null,
|
||||
totalPlayTime: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
isNewUser: false,
|
||||
data: user
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取进度失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 保存用户进度
|
||||
* POST /api/user/progress
|
||||
*/
|
||||
router.post('/progress', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const openid = user.openid
|
||||
|
||||
const {
|
||||
dim,
|
||||
data,
|
||||
dim1Score,
|
||||
dim2Score,
|
||||
dim3Score,
|
||||
dim4Score,
|
||||
dim5Score,
|
||||
unlockedDims,
|
||||
ownedSkins,
|
||||
isAdFree,
|
||||
isSubscriber,
|
||||
subscribeExpiry,
|
||||
totalPlayTime
|
||||
} = req.body
|
||||
|
||||
// 查找或创建用户(理论上已通过中间件找到)
|
||||
let dbUser = user
|
||||
|
||||
// 新版接口:按维度更新
|
||||
if (dim && data) {
|
||||
const dimData = dbUser.exploreData[dim] || {}
|
||||
let updatedDim = { ...dimData }
|
||||
|
||||
if (dim === 'dim1') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.collectedEggs = (dimData.collectedEggs || 0) + (data.newEggs || 0)
|
||||
updatedDim.totalLength = Math.max(data.totalLength || 0, dimData.totalLength || 0)
|
||||
} else if (dim === 'dim2') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestArea = Math.max(data.bestArea || 0, dimData.bestArea || 0)
|
||||
updatedDim.createdShapes = (dimData.createdShapes || 0) + (data.newShapes || 0)
|
||||
} else if (dim === 'dim3') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.exploredFaces = Math.max(data.exploredFaces || 0, dimData.exploredFaces || 0)
|
||||
updatedDim.rotationTime = (dimData.rotationTime || 0) + (data.rotationTime || 0)
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
} else if (dim === 'dim4') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.exploredEvents = (dimData.exploredEvents || 0) + (data.newEvents || 0)
|
||||
} else if (dim === 'dim5') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.exploredThoughts = (dimData.exploredThoughts || 0) + (data.newThoughts || 0)
|
||||
}
|
||||
|
||||
dbUser.exploreData[dim] = updatedDim
|
||||
|
||||
// 同步更新已解锁维度
|
||||
if (data.completed && !dbUser.unlockedDims.includes(parseInt(dim.replace('dim', '')) + 1)) {
|
||||
const dimNum = parseInt(dim.replace('dim', ''))
|
||||
if (!dbUser.unlockedDims.includes(dimNum + 1)) {
|
||||
dbUser.unlockedDims.push(dimNum + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (totalPlayTime !== null && totalPlayTime !== undefined) {
|
||||
dbUser.totalPlayTime += totalPlayTime
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧版接口:直接传各维度分数
|
||||
if (dim1Score !== null && dim1Score !== undefined) {
|
||||
dbUser.exploreData.dim1.bestScore = Math.max(dim1Score, dbUser.exploreData.dim1?.bestScore || 0)
|
||||
}
|
||||
if (dim2Score !== null && dim2Score !== undefined) {
|
||||
dbUser.exploreData.dim2.bestArea = Math.max(dim2Score, dbUser.exploreData.dim2?.bestArea || 0)
|
||||
}
|
||||
if (dim3Score !== null && dim3Score !== undefined) {
|
||||
dbUser.exploreData.dim3.bestScore = Math.max(dim3Score, dbUser.exploreData.dim3?.bestScore || 0)
|
||||
}
|
||||
if (dim4Score !== null && dim4Score !== undefined) {
|
||||
dbUser.exploreData.dim4 = dbUser.exploreData.dim4 || {}
|
||||
dbUser.exploreData.dim4.bestScore = Math.max(dim4Score, dbUser.exploreData.dim4?.bestScore || 0)
|
||||
}
|
||||
if (dim5Score !== null && dim5Score !== undefined) {
|
||||
dbUser.exploreData.dim5 = dbUser.exploreData.dim5 || {}
|
||||
dbUser.exploreData.dim5.bestScore = Math.max(dim5Score, dbUser.exploreData.dim5?.bestScore || 0)
|
||||
}
|
||||
|
||||
// 更新其他字段
|
||||
if (ownedSkins !== null && ownedSkins !== undefined) dbUser.ownedSkins = ownedSkins
|
||||
if (isAdFree !== null && isAdFree !== undefined) dbUser.isAdFree = isAdFree
|
||||
if (isSubscriber !== null && isSubscriber !== undefined) dbUser.isSubscriber = isSubscriber
|
||||
if (subscribeExpiry !== null && subscribeExpiry !== undefined) dbUser.subscribeExpiry = subscribeExpiry
|
||||
|
||||
await dbUser.save()
|
||||
|
||||
// 计算总分
|
||||
const totalScore = dbUser.calculateTotalScore()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
success: true,
|
||||
totalScore,
|
||||
exploreData: user.exploreData
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('保存进度失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取排行榜
|
||||
* GET /api/user/leaderboard
|
||||
*/
|
||||
router.get('/leaderboard', async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, dim } = req.query
|
||||
|
||||
const users = await User.getLeaderboard(dim ? parseInt(dim) : null, parseInt(limit))
|
||||
|
||||
const data = users.map((u, i) => ({
|
||||
rank: i + 1,
|
||||
openid: u.openid,
|
||||
nickname: u.nickName || '星辰旅者',
|
||||
avatar: u.avatarUrl || '',
|
||||
totalScore: u.totalScore || 0,
|
||||
dim1Score: u.dim1Score || 0,
|
||||
dim2Score: u.dim2Score || 0,
|
||||
dim3Score: u.dim3Score || 0,
|
||||
dim4Score: u.dim4Score || 0,
|
||||
dim5Score: u.dim5Score || 0,
|
||||
unlockedDims: u.unlockedDims || [1]
|
||||
}))
|
||||
|
||||
return ApiResponse.success(res, data)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取排行榜失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户成就
|
||||
* GET /api/user/achievements
|
||||
*/
|
||||
router.get('/achievements', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
// 生成成就数据
|
||||
const achievements = [
|
||||
{
|
||||
name: '探索成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'dim1_complete',
|
||||
name: '一维探索者',
|
||||
description: '完成一维空间的探索',
|
||||
icon: '⭐',
|
||||
unlocked: user.exploreData.dim1?.completed || false,
|
||||
date: user.exploreData.dim1?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim2_complete',
|
||||
name: '二维探索者',
|
||||
description: '完成二维空间的探索',
|
||||
icon: '✨',
|
||||
unlocked: user.exploreData.dim2?.completed || false,
|
||||
date: user.exploreData.dim2?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim3_complete',
|
||||
name: '三维探索者',
|
||||
description: '完成三维空间的探索',
|
||||
icon: '🌟',
|
||||
unlocked: user.exploreData.dim3?.completed || false,
|
||||
date: user.exploreData.dim3?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim4_complete',
|
||||
name: '四维探索者',
|
||||
description: '完成四维空间的探索',
|
||||
icon: '💫',
|
||||
unlocked: user.exploreData.dim4?.completed || false,
|
||||
date: user.exploreData.dim4?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim5_complete',
|
||||
name: '五维探索者',
|
||||
description: '完成五维空间的探索',
|
||||
icon: '🌌',
|
||||
unlocked: user.exploreData.dim5?.completed || false,
|
||||
date: user.exploreData.dim5?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '创作成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_work',
|
||||
name: '初次创作',
|
||||
description: '创作你的第一个作品',
|
||||
icon: '🎨',
|
||||
unlocked: user.achievements?.includes('first_work') || false,
|
||||
date: user.achievements?.includes('first_work') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'five_works',
|
||||
name: '创作达人',
|
||||
description: '创作5个作品',
|
||||
icon: '🏆',
|
||||
unlocked: user.achievements?.includes('five_works') || false,
|
||||
date: user.achievements?.includes('five_works') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'ten_works',
|
||||
name: '创作大师',
|
||||
description: '创作10个作品',
|
||||
icon: '👑',
|
||||
unlocked: user.achievements?.includes('ten_works') || false,
|
||||
date: user.achievements?.includes('ten_works') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '收藏成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_collection',
|
||||
name: '初次收藏',
|
||||
description: '收藏你的第一个知识',
|
||||
icon: '❤️',
|
||||
unlocked: user.collectedKnowledge?.length > 0,
|
||||
date: user.collectedKnowledge?.length > 0 ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'five_collections',
|
||||
name: '收藏爱好者',
|
||||
description: '收藏5个知识',
|
||||
icon: '🧡',
|
||||
unlocked: user.collectedKnowledge?.length >= 5,
|
||||
date: user.collectedKnowledge?.length >= 5 ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'ten_collections',
|
||||
name: '收藏大师',
|
||||
description: '收藏10个知识',
|
||||
icon: '💛',
|
||||
unlocked: user.collectedKnowledge?.length >= 10,
|
||||
date: user.collectedKnowledge?.length >= 10 ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '社交成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_share',
|
||||
name: '初次分享',
|
||||
description: '分享你的第一个作品',
|
||||
icon: '📤',
|
||||
unlocked: user.achievements?.includes('first_share') || false,
|
||||
date: user.achievements?.includes('first_share') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'five_shares',
|
||||
name: '分享达人',
|
||||
description: '分享5个作品',
|
||||
icon: '📣',
|
||||
unlocked: user.achievements?.includes('five_shares') || false,
|
||||
date: user.achievements?.includes('five_shares') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
return ApiResponse.success(res, achievements)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取成就失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户统计数据
|
||||
* GET /api/user/stats
|
||||
*/
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { Gallery } = require('../models')
|
||||
|
||||
// 获取用户作品数
|
||||
const worksCount = await Gallery.countDocuments({ userId: user._id })
|
||||
|
||||
// 获取等级信息
|
||||
const levelInfo = user.getLevelInfo()
|
||||
|
||||
// 计算探索时长(分钟转小时,保留1位小数)
|
||||
const playTimeHours = Math.round((user.totalPlayTime || 0) / 60 * 10) / 10
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
level: levelInfo.level,
|
||||
levelName: levelInfo.name,
|
||||
levelPoints: levelInfo.points,
|
||||
levelProgress: levelInfo.progress,
|
||||
totalProgress: levelInfo.totalProgress,
|
||||
nextLevelPoints: levelInfo.nextLevelPoints,
|
||||
totalPlayTime: playTimeHours,
|
||||
totalScore: user.totalScore || 0,
|
||||
worksCount: worksCount,
|
||||
collectionCount: user.collectedKnowledge?.length || 0,
|
||||
achievementCount: user.achievements?.length || 0,
|
||||
unlockedDims: user.unlockedDims || [1]
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取用户统计失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户作品列表
|
||||
* GET /api/user/works
|
||||
*/
|
||||
router.get('/works', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { page = 1, limit = 20 } = req.query
|
||||
const { Gallery } = require('../models')
|
||||
|
||||
const query = { userId: user._id }
|
||||
|
||||
const total = await Gallery.countDocuments(query)
|
||||
const works = await Gallery.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
// 格式化作品数据
|
||||
const formattedWorks = works.map(work => ({
|
||||
id: work._id,
|
||||
title: work.title,
|
||||
description: work.description,
|
||||
imageUrl: work.imageUrl,
|
||||
dimension: work.dimension,
|
||||
status: work.status,
|
||||
likes: work.likes || 0,
|
||||
views: work.views || 0,
|
||||
createdAt: work.createdAt
|
||||
}))
|
||||
|
||||
return ApiResponse.paginated(res, formattedWorks, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取用户作品失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 批量生成拼音音频URL脚本
|
||||
* 支持多种TTS服务:有道词典、百度语音、科大讯飞等
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 拼音列表
|
||||
const initials = ['b','p','m','f','d','t','n','l','g','k','h','j','q','x','zh','ch','sh','r','z','c','s','y','w'];
|
||||
const finals = ['a','o','e','i','u','ü','ai','ei','ui','ao','ou','iu','ie','üe','er','an','en','in','un','ün','ang','eng','ing','ong'];
|
||||
const overalls = ['zhi','chi','shi','ri','zi','ci','si','yi','wu','yu','ye','yue','yuan','yin','yun','ying'];
|
||||
|
||||
/**
|
||||
* 有道词典TTS服务
|
||||
* 优点:免费、无需注册、支持中文
|
||||
* 缺点:音质一般
|
||||
*/
|
||||
function getYoudaoUrl(text) {
|
||||
// 从环境变量读取TTS基础URL
|
||||
const ttsBaseUrl = process.env.TTS_BASE_URL || 'https://dict.youdao.com/dictvoice';
|
||||
return `${ttsBaseUrl}?audio=${encodeURIComponent(text)}&type=1`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成所有拼音的音频URL配置
|
||||
*/
|
||||
function generateAudioConfig() {
|
||||
// 从环境变量读取配置
|
||||
const ttsProvider = process.env.TTS_PROVIDER || 'youdao';
|
||||
const ttsBaseUrl = process.env.TTS_BASE_URL || 'https://dict.youdao.com/dictvoice';
|
||||
|
||||
const config = {
|
||||
// TTS服务提供商
|
||||
provider: ttsProvider,
|
||||
// 基础URL
|
||||
baseUrl: ttsBaseUrl,
|
||||
// 所有拼音的音频URL
|
||||
audios: {}
|
||||
};
|
||||
|
||||
// 声母
|
||||
initials.forEach(symbol => {
|
||||
config.audios[symbol] = {
|
||||
url: getYoudaoUrl(symbol),
|
||||
name: getPinyinName(symbol),
|
||||
type: 'initial'
|
||||
};
|
||||
});
|
||||
|
||||
// 韵母
|
||||
finals.forEach(symbol => {
|
||||
config.audios[symbol] = {
|
||||
url: getYoudaoUrl(symbol),
|
||||
name: getPinyinName(symbol),
|
||||
type: 'final'
|
||||
};
|
||||
});
|
||||
|
||||
// 整体认读
|
||||
overalls.forEach(symbol => {
|
||||
config.audios[symbol] = {
|
||||
url: getYoudaoUrl(symbol),
|
||||
name: getPinyinName(symbol),
|
||||
type: 'overall'
|
||||
};
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拼音名称
|
||||
*/
|
||||
function getPinyinName(symbol) {
|
||||
const nameMap = {
|
||||
'b': '玻', 'p': '坡', 'm': '摸', 'f': '佛',
|
||||
'd': '得', 't': '特', 'n': '讷', 'l': '勒',
|
||||
'g': '哥', 'k': '科', 'h': '喝',
|
||||
'j': '基', 'q': '欺', 'x': '希',
|
||||
'zh': '知', 'ch': '蚩', 'sh': '诗', 'r': '日',
|
||||
'z': '资', 'c': '雌', 's': '思',
|
||||
'y': '医', 'w': '巫',
|
||||
'a': '啊', 'o': '喔', 'e': '鹅', 'i': '衣', 'u': '乌', 'ü': '迂',
|
||||
'ai': '哀', 'ei': '诶', 'ui': '威', 'ao': '熬', 'ou': '欧',
|
||||
'iu': '优', 'ie': '耶', 'üe': '约', 'er': '儿',
|
||||
'an': '安', 'en': '恩', 'in': '因', 'un': '温', 'ün': '晕',
|
||||
'ang': '昂', 'eng': '亨', 'ing': '英', 'ong': '雍',
|
||||
'zhi': '织', 'chi': '吃', 'shi': '狮', 'ri': '日',
|
||||
'zi': '资', 'ci': '疵', 'si': '丝',
|
||||
'yi': '衣', 'wu': '乌', 'yu': '迂',
|
||||
'ye': '耶', 'yue': '约', 'yuan': '冤',
|
||||
'yin': '因', 'yun': '晕', 'ying': '英'
|
||||
};
|
||||
return nameMap[symbol] || symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成SQL更新语句(用于直接更新数据库)
|
||||
*/
|
||||
function generateSqlUpdates() {
|
||||
const config = generateAudioConfig();
|
||||
const sqls = [];
|
||||
|
||||
Object.entries(config.audios).forEach(([symbol, data]) => {
|
||||
const sql = `UPDATE pinyin_contents SET audio_url = '${data.url}' WHERE symbol = '${symbol}';`;
|
||||
sqls.push(sql);
|
||||
});
|
||||
|
||||
return sqls.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成MongoDB更新脚本
|
||||
*/
|
||||
function generateMongoScript() {
|
||||
const config = generateAudioConfig();
|
||||
const updates = [];
|
||||
|
||||
Object.entries(config.audios).forEach(([symbol, data]) => {
|
||||
updates.push({
|
||||
updateOne: {
|
||||
filter: { symbol: symbol },
|
||||
update: { $set: { audioUrl: data.url } }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return `db.pinyincontents.bulkWrite(${JSON.stringify(updates, null, 2)});`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置文件
|
||||
*/
|
||||
function saveConfig() {
|
||||
const config = generateAudioConfig();
|
||||
const outputPath = path.join(__dirname, '../../config/pinyin-audio-config.json');
|
||||
|
||||
// 确保目录存在
|
||||
const dir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(config, null, 2));
|
||||
console.log(`✅ 音频配置已保存到: ${outputPath}`);
|
||||
|
||||
// 生成SQL文件
|
||||
const sqlPath = path.join(__dirname, '../../config/update-audio-urls.sql');
|
||||
fs.writeFileSync(sqlPath, generateSqlUpdates());
|
||||
console.log(`✅ SQL更新脚本已保存到: ${sqlPath}`);
|
||||
|
||||
// 生成MongoDB脚本
|
||||
const mongoPath = path.join(__dirname, '../../config/update-audio-urls.js');
|
||||
fs.writeFileSync(mongoPath, generateMongoScript());
|
||||
console.log(`✅ MongoDB更新脚本已保存到: ${mongoPath}`);
|
||||
|
||||
// 输出统计
|
||||
console.log('\n📊 生成统计:');
|
||||
console.log(` - 声母: ${initials.length} 个`);
|
||||
console.log(` - 韵母: ${finals.length} 个`);
|
||||
console.log(` - 整体认读: ${overalls.length} 个`);
|
||||
console.log(` - 总计: ${initials.length + finals.length + overalls.length} 个拼音`);
|
||||
}
|
||||
|
||||
// 运行
|
||||
if (require.main === module) {
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateAudioConfig,
|
||||
generateSqlUpdates,
|
||||
generateMongoScript,
|
||||
getYoudaoUrl
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* AI 知识库初始化脚本
|
||||
* 为 5 个 AI 维度填充知识内容
|
||||
*
|
||||
* 用法:
|
||||
* cd /root/Hermes-workspace/yuzhiran-ai-dimension/backend/wdkj-server
|
||||
* node src/scripts/init-knowledge-ai.js
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const mongoose = require('mongoose');
|
||||
const Knowledge = require('../models/Knowledge');
|
||||
|
||||
// 数据库连接
|
||||
const DB_URI = process.env.MONGODB_URI || process.env.DB_URI || 'mongodb://127.0.0.1:27017/wdkj';
|
||||
|
||||
async function main() {
|
||||
console.log('[AI知识初始化] 开始连接数据库...');
|
||||
await mongoose.connect(DB_URI);
|
||||
console.log('[AI知识初始化] 数据库已连接:', DB_URI);
|
||||
|
||||
// 检查现有数据
|
||||
const existing = await Knowledge.countDocuments();
|
||||
if (existing > 0) {
|
||||
console.log(`[AI知识初始化] 发现 ${existing} 条现有记录`);
|
||||
console.log('[AI知识初始化] 继续追加(如需清空请先手动删除)');
|
||||
}
|
||||
|
||||
// 维度数据:key = dim 编号 (1-5)
|
||||
const dimensionData = {
|
||||
1: [
|
||||
{
|
||||
title: '图灵测试:人工智能的哲学起点',
|
||||
content: '1950 年,英国数学家艾伦·图灵发表了划时代的论文《计算机器与智能》,提出了著名的"图灵测试"——如果一台机器能与人类进行对话而不被辨别出机器身份,就可以认为它具备智能。图灵测试不是技术标准,而是哲学思辨:"机器能思考吗?"这个问题开启了 AI 研究的序幕。',
|
||||
dim: 1, category: 'history', tags: ['图灵测试', '艾伦·图灵', 'AI 哲学'],
|
||||
isPremium: false, sortOrder: 1, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '达特茅斯会议:AI 的诞生',
|
||||
content: '1956 年夏天,约翰·麦卡锡、马文·明斯基、克劳德·香农等科学家在达特茅斯学院聚会,首次提出"Artificial Intelligence"(人工智能)一词,标志着 AI 作为独立学科的正式诞生。会议上定义了 AI 的核心目标:让机器模拟人类智能行为——学习、推理、感知、决策。',
|
||||
dim: 1, category: 'history', tags: ['达特茅斯', '麦卡锡', 'AI 起源'],
|
||||
isPremium: false, sortOrder: 2, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '深蓝击败卡斯帕罗夫(1997)',
|
||||
content: '1997 年 5 月,IBM 的深蓝(Deep Blue)在国际象棋比赛中以 3.5:2.5 击败世界冠军加里·卡斯帕罗夫。这是 AI 第一次在正式比赛中战胜人类世界冠军,具有里程碑意义。深蓝的胜利展示了暴力搜索和硬编码知识的威力,也引发了对 AI 威胁论的激烈讨论。',
|
||||
dim: 1, category: 'history', tags: ['深蓝', 'IBM', '卡斯帕罗夫'],
|
||||
isPremium: false, sortOrder: 3, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'AlphaGo 与深度学习革命(2016)',
|
||||
content: '2016 年 3 月,Google DeepMind 的 AlphaGo 以 4:1 击败围棋世界冠军李世石。围棋曾被视为人类智力的最后堡垒,AlphaGo 的胜利震惊世界。AlphaGo 结合了深度神经网络和蒙特卡洛树搜索,开创了 AI 的新纪元,直接推动了深度学习在各行各业的爆发式应用。',
|
||||
dim: 1, category: 'history', tags: ['AlphaGo', '深度学习', 'DeepMind'],
|
||||
isPremium: true, sortOrder: 4, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '从 GPT 到 DeepSeek:大语言模型时代',
|
||||
content: '2018 年 OpenAI 发布 GPT-1,开启了基于 Transformer 的预训练范式。2022 年 ChatGPT 引爆全球,展示了生成式 AI 的惊人能力。2024-2026 年,以 DeepSeek、Llama、Qwen 为代表的开源大模型迅速崛起,AI 真正走进千家万户,成为生产力工具。',
|
||||
dim: 1, category: 'history', tags: ['GPT', 'DeepSeek', '大语言模型'],
|
||||
isPremium: true, sortOrder: 5, status: 'approved'
|
||||
}
|
||||
],
|
||||
|
||||
2: [
|
||||
{
|
||||
title: '机器学习与深度学习基础',
|
||||
content: '机器学习是 AI 的核心分支,让计算机从数据中自动学习规律,而不需要显式编程。深度学习是机器学习的一个子集,使用多层神经网络来处理复杂模式。从 2012 年 AlexNet 在 ImageNet 夺冠开始,深度学习席卷所有领域。',
|
||||
dim: 2, category: 'concept', tags: ['机器学习', '深度学习', '神经网络'],
|
||||
isPremium: false, sortOrder: 1, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'Transformer:改变一切的架构',
|
||||
content: '2017 年,Google 在论文《Attention Is All You Need》中提出 Transformer 架构,核心是自注意力机制(Self-Attention),让模型可以并行处理序列,突破 RNN 的瓶颈。Transformer 成为后来所有大模型(GPT、BERT、T5)的基础,是 AI 历史上最重要的架构创新之一。',
|
||||
dim: 2, category: 'concept', tags: ['Transformer', '注意力机制', 'Google'],
|
||||
isPremium: false, sortOrder: 2, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '从 BERT 到 GPT:预训练模型的演进',
|
||||
content: 'BERT(2018)展示了双向编码器的强大,GPT(2018)展示了自回归生成的能力。随着模型规模指数级增长(参数从亿到万亿),涌现出前所未有的能力——这就是规模定律(Scaling Law)。GPT 系列代表了一条路:预训练 + 指令微调 + RLHF,成为今天大语言模型的标准配方。',
|
||||
dim: 2, category: 'application', tags: ['BERT', 'GPT', '预训练'],
|
||||
isPremium: false, sortOrder: 3, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '多模态 AI:从文本到全感官',
|
||||
content: '2022 年后,AI 不再局限于文本。DALL·E、Midjourney、Stable Diffusion 让 AI 绘画普及;Sora、Kling 让 AI 生成视频成为现实;Suno 让 AI 创作音乐。多模态(Multimodal)成为大模型的新标准:一个模型同时理解文本、图像、声音,打通感官壁垒。',
|
||||
dim: 2, category: 'application', tags: ['多模态', '文生图', 'AI 视频'],
|
||||
isPremium: true, sortOrder: 4, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'MoE 架构:万亿参数的经济之选',
|
||||
content: '混合专家模型(Mixture of Experts, MoE)让模型参数规模达到万亿级别,但推理时只激活部分参数,显著提升性能同时控制成本。GPT-4、Mixtral、DeepSeek 等均已采用 MoE,成为大模型追求性能的终极架构。',
|
||||
dim: 2, category: 'concept', tags: ['MoE', '万亿参数', '架构'],
|
||||
isPremium: true, sortOrder: 5, status: 'approved'
|
||||
}
|
||||
],
|
||||
|
||||
3: [
|
||||
{
|
||||
title: 'OpenAI vs Anthropic:闭源双雄争霸',
|
||||
content: 'OpenAI 的 GPT-4/4o 系列占据高端市场,Anthropic 的 Claude 3 系列以安全和推理见长。两者代表了闭源大模型的最高水平,但商业化策略不同:OpenAI 走 B2B2C,Anthropic 专注企业市场。',
|
||||
dim: 3, category: 'application', tags: ['OpenAI', 'Anthropic', '闭源模型'],
|
||||
isPremium: false, sortOrder: 1, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'DeepSeek:中国开源力量的崛起',
|
||||
content: 'DeepSeek(深度求索)是中国 AI 公司的代表,其 DeepSeek-V3 在多项评测中逼近 GPT-4,同时大力开源(DeepSeek-V2、Coder 系列)。DeepSeek 展示了中国团队在大模型领域的技术实力。',
|
||||
dim: 3, category: 'application', tags: ['DeepSeek', '中国 AI', '开源'],
|
||||
isPremium: false, sortOrder: 2, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '开源 vs 闭源:生态之争',
|
||||
content: '闭源模型(OpenAI、Anthropic)性能领先但成本高、可控性差;开源模型(Llama 3、DeepSeek、Qwen)灵活部署、可定制,生态快速繁荣。2026 年,开源生态已形成完整工具链(vLLM、Ollama、Hugging Face),成为中小企业首选。',
|
||||
dim: 3, category: 'concept', tags: ['开源', '闭源', '生态'],
|
||||
isPremium: false, sortOrder: 3, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'Agent 智能体:从 Chatbot 到自主智能',
|
||||
content: 'Agent 是新一代 AI 形态,不仅能对话,还能规划、使用工具、执行任务。AutoGPT、MetaGPT 展示了潜力;Claude 的 Tool Use、OpenAI 的 GPTs 让 Agent 更实用。Agent 是通向 AGI 的关键路径。',
|
||||
dim: 3, category: 'application', tags: ['Agent', '自主智能'],
|
||||
isPremium: true, sortOrder: 4, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '具身智能与世界模型',
|
||||
content: '具身智能(Embodied AI)让 AI 拥有"身体",能够感知和操作物理世界。Google 的 RT-2、DeepMind 的 RoboCat 展示了机器人与大模型结合的前景。世界模型(World Model)让 AI 理解物理规律,模拟环境,是具身智能的核心。',
|
||||
dim: 3, category: 'concept', tags: ['具身智能', '世界模型', '机器人'],
|
||||
isPremium: true, sortOrder: 5, status: 'approved'
|
||||
}
|
||||
],
|
||||
|
||||
4: [
|
||||
{
|
||||
title: '提示词工程入门',
|
||||
content: '好的 prompt 能显著提升 AI 输出质量。提示词工程包括:角色设定、任务描述、输出格式、few-shot 示例、思维链(Chain-of-Thought)。学会写 prompt,是使用大模型的基本功。',
|
||||
dim: 4, category: 'concept', tags: ['提示词', 'Prompt Engineering'],
|
||||
isPremium: false, sortOrder: 1, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '检索增强生成(RAG)详解',
|
||||
content: 'RAG (Retrieval-Augmented Generation) 将外部知识库与 LLM 结合,解决大模型知识滞后和幻觉问题。流程:用户提问 → 向量检索相关文档 → 拼接 prompt → LLM 生成答案。RAG 是企业知识库 AI 化的核心技术。',
|
||||
dim: 4, category: 'concept', tags: ['RAG', '检索增强', '知识库'],
|
||||
isPremium: false, sortOrder: 2, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '模型微调(Fine-tuning)实战',
|
||||
content: '微调是在预训练模型基础上用领域数据进一步训练,让模型适应特定任务。常用方法:全量微调、LoRA(低秩适应)、QLoRA(量化 LoRA)。小数据也能做微调,但要注意过拟合和灾难性遗忘。',
|
||||
dim: 4, category: 'application', tags: ['Fine-tuning', 'LoRA', '微调'],
|
||||
isPremium: true, sortOrder: 3, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'LangChain:AI 应用开发框架',
|
||||
content: 'LangChain 是最流行的 LLM 应用框架,提供模型调用、提示模板、记忆管理、工具链、Agent 等模块,大大降低开发难度。用它快速搭建 RAG 应用、聊天机器人、数据分析工具。',
|
||||
dim: 4, category: 'application', tags: ['LangChain', '开发框架'],
|
||||
isPremium: true, sortOrder: 4, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'Dify:无代码 AI 平台',
|
||||
content: 'Dify 是开源的 LLMOps 平台,可视化编排 AI 工作流,支持 RAG、Agent、知识库,无需写代码即可构建企业级 AI 应用。适合业务人员快速落地 AI 解决方案。',
|
||||
dim: 4, category: 'application', tags: ['Dify', '低代码'],
|
||||
isPremium: true, sortOrder: 5, status: 'approved'
|
||||
}
|
||||
],
|
||||
|
||||
5: [
|
||||
{
|
||||
title: 'WAIC 2026:世界人工智能大会即将开幕',
|
||||
content: '2026 年世界人工智能大会(WAIC)将于 7 月 17 日在上海开幕,预计将有 1100+ 企业参展,发布众多前沿技术和产品。WAIC 是了解 AI 趋势的绝佳窗口。',
|
||||
dim: 5, category: 'concept', tags: ['WAIC', '会议'],
|
||||
isPremium: false, sortOrder: 1, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'AI 法规与伦理:全球监管趋势',
|
||||
content: '各国正在加快 AI 立法:欧盟 AI Act 已生效,美国 NIST AI RMF 发布,中国《生成式 AI 服务管理暂行办法》持续完善。安全、可控、可信成为 AI 发展的重要议题。',
|
||||
dim: 5, category: 'concept', tags: ['AI 伦理', '法规'],
|
||||
isPremium: false, sortOrder: 2, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'Agent 将如何改变软件交互方式?',
|
||||
content: 'Agent 让软件从"人找功能"变为"AI 帮我做事"。未来的办公软件、设计工具、数据分析平台都将以 Agent 为核心交互。一些专家预测:2027 年 Agent 将替代 30% 的重复性办公任务。',
|
||||
dim: 5, category: 'application', tags: ['Agent', '未来趋势'],
|
||||
isPremium: true, sortOrder: 3, status: 'approved'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const dims = [1, 2, 3, 4, 5];
|
||||
let totalInserted = 0;
|
||||
|
||||
for (const dim of dims) {
|
||||
const entries = dimensionData[dim];
|
||||
if (!entries) continue;
|
||||
console.log(`[维度${dim}] 开始插入 ${entries.length} 条知识...`);
|
||||
try {
|
||||
const result = await Knowledge.insertMany(entries);
|
||||
totalInserted += result.length;
|
||||
console.log(`[维度${dim}] 已插入 ${result.length} 条`);
|
||||
} catch (err) {
|
||||
console.error(`[维度${dim}] 插入失败:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const total = await Knowledge.countDocuments();
|
||||
console.log(`✅ 完成!Knowledge 库现有 ${total} 条记录(本次新增 ${totalInserted} 条)`);
|
||||
await mongoose.disconnect();
|
||||
console.log('数据库连接已关闭');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('❌ 初始化失败:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 拼音数据初始化脚本
|
||||
* 创建所有63个拼音的基础数据
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const mongoose = require('mongoose');
|
||||
const { PinyinContent } = require('../models/pinyin');
|
||||
const { PinyinAchievement } = require('../models/pinyin');
|
||||
|
||||
// 声母表 (23个)
|
||||
const initials = [
|
||||
{ symbol: 'b', name: '玻', order: 1 },
|
||||
{ symbol: 'p', name: '坡', order: 2 },
|
||||
{ symbol: 'm', name: '摸', order: 3 },
|
||||
{ symbol: 'f', name: '佛', order: 4 },
|
||||
{ symbol: 'd', name: '得', order: 5 },
|
||||
{ symbol: 't', name: '特', order: 6 },
|
||||
{ symbol: 'n', name: '讷', order: 7 },
|
||||
{ symbol: 'l', name: '勒', order: 8 },
|
||||
{ symbol: 'g', name: '哥', order: 9 },
|
||||
{ symbol: 'k', name: '科', order: 10 },
|
||||
{ symbol: 'h', name: '喝', order: 11 },
|
||||
{ symbol: 'j', name: '基', order: 12 },
|
||||
{ symbol: 'q', name: '欺', order: 13 },
|
||||
{ symbol: 'x', name: '希', order: 14 },
|
||||
{ symbol: 'zh', name: '知', order: 15 },
|
||||
{ symbol: 'ch', name: '蚩', order: 16 },
|
||||
{ symbol: 'sh', name: '诗', order: 17 },
|
||||
{ symbol: 'r', name: '日', order: 18 },
|
||||
{ symbol: 'z', name: '资', order: 19 },
|
||||
{ symbol: 'c', name: '雌', order: 20 },
|
||||
{ symbol: 's', name: '思', order: 21 },
|
||||
{ symbol: 'y', name: '医', order: 22 },
|
||||
{ symbol: 'w', name: '巫', order: 23 }
|
||||
];
|
||||
|
||||
// 韵母表 (24个)
|
||||
const finals = [
|
||||
{ symbol: 'a', name: '啊', order: 1 },
|
||||
{ symbol: 'o', name: '喔', order: 2 },
|
||||
{ symbol: 'e', name: '鹅', order: 3 },
|
||||
{ symbol: 'i', name: '衣', order: 4 },
|
||||
{ symbol: 'u', name: '乌', order: 5 },
|
||||
{ symbol: 'ü', name: '迂', order: 6 },
|
||||
{ symbol: 'ai', name: '哀', order: 7 },
|
||||
{ symbol: 'ei', name: '诶', order: 8 },
|
||||
{ symbol: 'ui', name: '威', order: 9 },
|
||||
{ symbol: 'ao', name: '熬', order: 10 },
|
||||
{ symbol: 'ou', name: '欧', order: 11 },
|
||||
{ symbol: 'iu', name: '优', order: 12 },
|
||||
{ symbol: 'ie', name: '耶', order: 13 },
|
||||
{ symbol: 'üe', name: '约', order: 14 },
|
||||
{ symbol: 'er', name: '儿', order: 15 },
|
||||
{ symbol: 'an', name: '安', order: 16 },
|
||||
{ symbol: 'en', name: '恩', order: 17 },
|
||||
{ symbol: 'in', name: '因', order: 18 },
|
||||
{ symbol: 'un', name: '温', order: 19 },
|
||||
{ symbol: 'ün', name: '晕', order: 20 },
|
||||
{ symbol: 'ang', name: '昂', order: 21 },
|
||||
{ symbol: 'eng', name: '亨', order: 22 },
|
||||
{ symbol: 'ing', name: '英', order: 23 },
|
||||
{ symbol: 'ong', name: '雍', order: 24 }
|
||||
];
|
||||
|
||||
// 整体认读音节 (16个)
|
||||
const overalls = [
|
||||
{ symbol: 'zhi', name: '织', order: 1 },
|
||||
{ symbol: 'chi', name: '吃', order: 2 },
|
||||
{ symbol: 'shi', name: '狮', order: 3 },
|
||||
{ symbol: 'ri', name: '日', order: 4 },
|
||||
{ symbol: 'zi', name: '资', order: 5 },
|
||||
{ symbol: 'ci', name: '疵', order: 6 },
|
||||
{ symbol: 'si', name: '丝', order: 7 },
|
||||
{ symbol: 'yi', name: '衣', order: 8 },
|
||||
{ symbol: 'wu', name: '乌', order: 9 },
|
||||
{ symbol: 'yu', name: '迂', order: 10 },
|
||||
{ symbol: 'ye', name: '耶', order: 11 },
|
||||
{ symbol: 'yue', name: '约', order: 12 },
|
||||
{ symbol: 'yuan', name: '冤', order: 13 },
|
||||
{ symbol: 'yin', name: '因', order: 14 },
|
||||
{ symbol: 'yun', name: '晕', order: 15 },
|
||||
{ symbol: 'ying', name: '英', order: 16 }
|
||||
];
|
||||
|
||||
// 示例词语数据
|
||||
const sampleWords = {
|
||||
'b': [
|
||||
{ word: '爸爸', pinyin: 'bàba', meaning: '父亲' },
|
||||
{ word: '杯子', pinyin: 'bēizi', meaning: '装水的器具' },
|
||||
{ word: '白云', pinyin: 'báiyún', meaning: '白色的云' }
|
||||
],
|
||||
'a': [
|
||||
{ word: '妈妈', pinyin: 'māma', meaning: '母亲' },
|
||||
{ word: '阿姨', pinyin: 'āyí', meaning: '母亲的姐妹' }
|
||||
],
|
||||
'zhi': [
|
||||
{ word: '知道', pinyin: 'zhīdào', meaning: '了解' },
|
||||
{ word: '蜘蛛', pinyin: 'zhīzhū', meaning: '一种昆虫' }
|
||||
]
|
||||
};
|
||||
|
||||
// 发音方法说明
|
||||
const pronunciationGuides = {
|
||||
'b': '双唇紧闭,阻碍气流,然后双唇突然放开,让气流冲出,读音轻短。',
|
||||
'p': '双唇紧闭,阻碍气流,然后双唇突然放开,气流较强地冲出。',
|
||||
'm': '双唇紧闭,软腭下降,气流从鼻腔出来,声带振动。',
|
||||
'f': '上齿接触下唇,形成缝隙,气流从缝隙中摩擦出来。',
|
||||
'a': '嘴巴张大,舌头放平,舌位低,声音响亮。',
|
||||
'o': '嘴巴圆圆,舌头后缩,舌位半高。'
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成音频URL(从环境变量读取TTS配置)
|
||||
*/
|
||||
function getAudioUrl(symbol) {
|
||||
// 从环境变量读取TTS基础URL,默认使用有道词典
|
||||
const ttsBaseUrl = process.env.TTS_BASE_URL || 'https://dict.youdao.com/dictvoice';
|
||||
const ttsProvider = process.env.TTS_PROVIDER || 'youdao';
|
||||
|
||||
// 如果使用自定义CDN
|
||||
if (ttsProvider === 'custom' && process.env.TTS_CUSTOM_CDN_URL) {
|
||||
return `${process.env.TTS_CUSTOM_CDN_URL}/${encodeURIComponent(symbol)}.mp3`;
|
||||
}
|
||||
|
||||
// 默认使用有道词典格式
|
||||
return `${ttsBaseUrl}?audio=${encodeURIComponent(symbol)}&type=1`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成口型图URL(从环境变量读取CDN配置)
|
||||
*/
|
||||
function getMouthImage(symbol) {
|
||||
// 从环境变量读取口型图CDN
|
||||
const mouthImageCdn = process.env.MOUTH_IMAGE_CDN_URL;
|
||||
|
||||
if (mouthImageCdn) {
|
||||
return `${mouthImageCdn}/mouth_${encodeURIComponent(symbol)}.png`;
|
||||
}
|
||||
|
||||
// 默认返回空,由管理后台上传
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建拼音内容数据
|
||||
*/
|
||||
async function createPinyinContents() {
|
||||
const contents = [];
|
||||
|
||||
// 处理声母
|
||||
for (const item of initials) {
|
||||
const words = sampleWords[item.symbol] || [
|
||||
{ word: item.name, pinyin: item.symbol, meaning: '示例词语' }
|
||||
];
|
||||
|
||||
contents.push({
|
||||
symbol: item.symbol,
|
||||
type: 'initial',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
audioUrl: getAudioUrl(item.symbol),
|
||||
mouthImage: getMouthImage(item.symbol),
|
||||
pronunciation: pronunciationGuides[item.symbol] || `${item.name}的发音`,
|
||||
isFree: item.order <= 5, // 前5个免费
|
||||
words: words.map(w => ({
|
||||
...w,
|
||||
audioUrl: getAudioUrl(w.word),
|
||||
image: ''
|
||||
})),
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理韵母
|
||||
for (const item of finals) {
|
||||
const words = sampleWords[item.symbol] || [
|
||||
{ word: item.name, pinyin: item.symbol, meaning: '示例词语' }
|
||||
];
|
||||
|
||||
contents.push({
|
||||
symbol: item.symbol,
|
||||
type: 'final',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
audioUrl: getAudioUrl(item.symbol),
|
||||
mouthImage: getMouthImage(item.symbol),
|
||||
pronunciation: pronunciationGuides[item.symbol] || `${item.name}的发音`,
|
||||
isFree: item.order <= 3, // 前3个免费
|
||||
words: words.map(w => ({
|
||||
...w,
|
||||
audioUrl: getAudioUrl(w.word),
|
||||
image: ''
|
||||
})),
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理整体认读
|
||||
for (const item of overalls) {
|
||||
const words = sampleWords[item.symbol] || [
|
||||
{ word: item.name, pinyin: item.symbol, meaning: '示例词语' }
|
||||
];
|
||||
|
||||
contents.push({
|
||||
symbol: item.symbol,
|
||||
type: 'overall',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
audioUrl: getAudioUrl(item.symbol),
|
||||
mouthImage: getMouthImage(item.symbol),
|
||||
pronunciation: pronunciationGuides[item.symbol] || `${item.name}的发音`,
|
||||
isFree: item.order <= 2, // 前2个免费
|
||||
words: words.map(w => ({
|
||||
...w,
|
||||
audioUrl: getAudioUrl(w.word),
|
||||
image: ''
|
||||
})),
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成就数据
|
||||
*/
|
||||
async function createAchievements() {
|
||||
const achievements = [
|
||||
{
|
||||
code: 'first_explore',
|
||||
name: '初次探索',
|
||||
description: '完成第一次拼音探索',
|
||||
type: 'explore',
|
||||
icon: 'rocket',
|
||||
condition: { type: 'explore_count', value: 1 },
|
||||
reward: { type: 'stone', value: 1 },
|
||||
order: 1,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'explore_10',
|
||||
name: '探索新手',
|
||||
description: '累计探索10个拼音',
|
||||
type: 'explore',
|
||||
icon: 'star',
|
||||
condition: { type: 'explore_count', value: 10 },
|
||||
reward: { type: 'stone', value: 5 },
|
||||
order: 2,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'explore_50',
|
||||
name: '探索达人',
|
||||
description: '累计探索50个拼音',
|
||||
type: 'explore',
|
||||
icon: 'trophy',
|
||||
condition: { type: 'explore_count', value: 50 },
|
||||
reward: { type: 'stone', value: 20 },
|
||||
order: 3,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'collect_10',
|
||||
name: '收集者',
|
||||
description: '收集10个能量石',
|
||||
type: 'collection',
|
||||
icon: 'gem',
|
||||
condition: { type: 'collect_count', value: 10 },
|
||||
reward: { type: 'stone', value: 5 },
|
||||
order: 4,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'collect_all',
|
||||
name: '收集大师',
|
||||
description: '收集所有63个能量石',
|
||||
type: 'collection',
|
||||
icon: 'crown',
|
||||
condition: { type: 'collect_count', value: 63 },
|
||||
reward: { type: 'stone', value: 50 },
|
||||
order: 5,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'streak_7',
|
||||
name: '坚持一周',
|
||||
description: '连续7天进行拼音探索',
|
||||
type: 'streak',
|
||||
icon: 'fire',
|
||||
condition: { type: 'streak_days', value: 7 },
|
||||
reward: { type: 'stone', value: 10 },
|
||||
order: 6,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'complete_symbol_b',
|
||||
name: 'b的探索者',
|
||||
description: '完成拼音b的所有探索',
|
||||
type: 'special',
|
||||
icon: 'check-circle',
|
||||
condition: { type: 'complete_symbol', value: 1, symbol: 'b' },
|
||||
reward: { type: 'stone', value: 2 },
|
||||
order: 7,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'game_master',
|
||||
name: '游戏高手',
|
||||
description: '趣味互动累计获得1000分',
|
||||
type: 'special',
|
||||
icon: 'gamepad',
|
||||
condition: { type: 'game_score', value: 1000 },
|
||||
reward: { type: 'stone', value: 15 },
|
||||
order: 8,
|
||||
isActive: true
|
||||
}
|
||||
];
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化拼音数据
|
||||
*/
|
||||
async function initPinyinData() {
|
||||
try {
|
||||
// 连接数据库
|
||||
const mongoURI = process.env.MONGODB_URI ||
|
||||
`mongodb://${process.env.MONGODB_USER}:${process.env.MONGODB_PASSWORD}@${process.env.MONGODB_HOST}:${process.env.MONGODB_PORT}/${process.env.MONGODB_DB}?authSource=${process.env.MONGODB_DB}`;
|
||||
await mongoose.connect(mongoURI);
|
||||
console.log('数据库连接成功');
|
||||
|
||||
// 清空现有数据(可选,生产环境慎用)
|
||||
const clearExisting = process.env.CLEAR_EXISTING === 'true';
|
||||
if (clearExisting) {
|
||||
await PinyinContent.deleteMany({});
|
||||
await PinyinAchievement.deleteMany({});
|
||||
console.log('已清空现有数据');
|
||||
}
|
||||
|
||||
// 检查是否已有数据
|
||||
const existingCount = await PinyinContent.countDocuments();
|
||||
if (existingCount > 0 && !clearExisting) {
|
||||
console.log(`数据库中已有 ${existingCount} 条拼音数据,跳过初始化`);
|
||||
console.log('如需重新初始化,请设置环境变量 CLEAR_EXISTING=true');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 创建拼音内容
|
||||
const contents = await createPinyinContents();
|
||||
await PinyinContent.insertMany(contents);
|
||||
console.log(`成功创建 ${contents.length} 个拼音内容`);
|
||||
|
||||
// 创建成就
|
||||
const achievements = await createAchievements();
|
||||
await PinyinAchievement.insertMany(achievements);
|
||||
console.log(`成功创建 ${achievements.length} 个成就`);
|
||||
|
||||
console.log('拼音数据初始化完成!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('初始化失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行初始化
|
||||
if (require.main === module) {
|
||||
initPinyinData();
|
||||
}
|
||||
|
||||
module.exports = { initPinyinData, createPinyinContents, createAchievements };
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 更新商品定价为 AI 维度主题
|
||||
* 用法: node src/scripts/update-products.js
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mongoose = require('mongoose');
|
||||
const ShopItem = require('../models/ShopItem');
|
||||
|
||||
const DB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/wdkj';
|
||||
|
||||
async function main() {
|
||||
await mongoose.connect(DB_URI);
|
||||
console.log('数据库已连接');
|
||||
|
||||
// 清空旧商品
|
||||
await ShopItem.deleteMany({});
|
||||
console.log('旧商品已清空');
|
||||
|
||||
const products = [
|
||||
{
|
||||
itemId: 'pro_monthly',
|
||||
name: 'AI维度 Pro (月付)',
|
||||
description: '无限 AI 问答 + 全部知识解锁 + 趋势跟踪',
|
||||
price: 1990, // 分 = ¥19.90
|
||||
originalPrice: 2990,
|
||||
type: 'subscription',
|
||||
duration: 30,
|
||||
sortOrder: 1,
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
itemId: 'pro_quarterly',
|
||||
name: 'AI维度 Pro (季付)',
|
||||
description: '无限 AI 问答 + 全部知识解锁 + 趋势跟踪,享 8 折',
|
||||
price: 4990, // ¥49.90
|
||||
originalPrice: 5970,
|
||||
type: 'subscription',
|
||||
duration: 90,
|
||||
sortOrder: 2,
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
itemId: 'vip_monthly',
|
||||
name: 'AI维度 VIP (月付)',
|
||||
description: 'Pro + 工具教程 + 学习路径定制 + 社区 + 优先支持',
|
||||
price: 3990, // ¥39.90
|
||||
originalPrice: 5990,
|
||||
type: 'subscription',
|
||||
duration: 30,
|
||||
sortOrder: 3,
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
itemId: 'vip_quarterly',
|
||||
name: 'AI维度 VIP (季付)',
|
||||
description: 'VIP全套权益,享 8 折优惠',
|
||||
price: 9990, // ¥99.90
|
||||
originalPrice: 11970,
|
||||
type: 'subscription',
|
||||
duration: 90,
|
||||
sortOrder: 4,
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
itemId: 'noad',
|
||||
name: '去广告',
|
||||
description: '永久去除广告,纯净体验',
|
||||
price: 1200, // ¥12.00
|
||||
type: 'noad',
|
||||
duration: 0,
|
||||
sortOrder: 5,
|
||||
status: 'active'
|
||||
}
|
||||
];
|
||||
|
||||
const result = await ShopItem.insertMany(products);
|
||||
console.log(`✅ 已插入 ${result.length} 个商品:`);
|
||||
result.forEach(p => console.log(` - ${p.name}: ¥${(p.price/100).toFixed(2)}`));
|
||||
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('❌', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 趋势数据更新脚本(兼容 Hermes cron 调用)
|
||||
* 从 AI 新闻源获取最新趋势并写入数据库
|
||||
*
|
||||
* 用法: node src/scripts/update-trends.js
|
||||
* 或由 Hermes cron 每周调用
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mongoose = require('mongoose');
|
||||
const Trend = require('../models/Trend');
|
||||
|
||||
const DB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/wdkj';
|
||||
|
||||
// 示例新闻数据(生产环境可从 RSS/API 获取)
|
||||
const sampleTrends = [
|
||||
{
|
||||
title: 'DeepSeek 秘密造芯:推理芯片项目已启动一年',
|
||||
summary: '据报道,DeepSeek 正在自研推理芯片,旨在降低对英伟达的依赖。',
|
||||
source: '路透社', sourceUrl: 'https://reuters.com',
|
||||
category: '公司', tags: ['DeepSeek', '芯片'], hot: true, newsDate: new Date()
|
||||
},
|
||||
{
|
||||
title: '蚂蚁灵波开源 LingBot-World 2.0,世界模型小时级实时生成',
|
||||
summary: 'LingBot-World 2.0 实现了世界模型的小时级实时生成,是具身智能的重要突破。',
|
||||
source: '量子位', sourceUrl: 'https://qbitai.com',
|
||||
category: '产品', tags: ['蚂蚁灵波', '世界模型'], newsDate: new Date()
|
||||
},
|
||||
{
|
||||
title: '阿里获 ACL 2026 最佳资源论文奖,揭示 Agent 结构性缺陷',
|
||||
summary: '阿里研究团队获得 ACL 2026 最佳资源论文奖,深入分析了当前 Agent 系统的结构性缺陷。',
|
||||
source: '机器之心', sourceUrl: 'https://jiqizhixin.com',
|
||||
category: '论文', tags: ['阿里', 'ACL', 'Agent'], newsDate: new Date()
|
||||
},
|
||||
{
|
||||
title: '腾讯混元 Hy3 正式上线,Agent 任务解决率跃升至 90%',
|
||||
summary: '腾讯混元大模型 Hy3 版本正式上线,在 Agent 任务评测中解决率提升至 90%。',
|
||||
source: '腾讯云', sourceUrl: 'https://cloud.tencent.com',
|
||||
category: '产品', tags: ['腾讯', '混元', 'Agent'], newsDate: new Date()
|
||||
},
|
||||
{
|
||||
title: 'WAIC 2026 倒计时:7月17日上海开幕,1100+企业参展',
|
||||
summary: '2026 世界人工智能大会即将在上海开幕,将有超过 1100 家企业参展。',
|
||||
source: '量子位', sourceUrl: 'https://qbitai.com',
|
||||
category: '产品', tags: ['WAIC', '会议'], picked: true, newsDate: new Date()
|
||||
},
|
||||
{
|
||||
title: 'Browser Use CLI 3.0 发布:体积缩小 6 倍,Token 消耗大幅降低',
|
||||
summary: 'Browser Use 工具 CLI 3.0 版本发布,体积缩小 6 倍,Token 消耗显著降低。',
|
||||
source: 'GitHub', sourceUrl: 'https://github.com',
|
||||
category: '工具', tags: ['Browser Use', '开源'], newsDate: new Date()
|
||||
}
|
||||
];
|
||||
|
||||
async function main() {
|
||||
await mongoose.connect(DB_URI);
|
||||
console.log('数据库已连接');
|
||||
|
||||
// 清除 7 天前的旧数据
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const deleted = await Trend.deleteMany({ newsDate: { $lt: weekAgo } });
|
||||
console.log(`已清理 ${deleted.deletedCount} 条旧数据`);
|
||||
|
||||
// 插入新数据(去重)
|
||||
let inserted = 0;
|
||||
for (const item of sampleTrends) {
|
||||
const exists = await Trend.findOne({ title: item.title });
|
||||
if (!exists) {
|
||||
await new Trend({ ...item, status: 'published' }).save();
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
console.log(`新增 ${inserted} 条趋势数据`);
|
||||
|
||||
await mongoose.disconnect();
|
||||
console.log('完成');
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('❌', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,96 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
/**
|
||||
* 日志级别
|
||||
*/
|
||||
const LOG_LEVELS = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志器
|
||||
*/
|
||||
class Logger {
|
||||
constructor(level = 'info', logDir = './logs') {
|
||||
this.level = LOG_LEVELS[level] || LOG_LEVELS.info
|
||||
this.logDir = logDir
|
||||
|
||||
// 确保日志目录存在
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日志消息
|
||||
*/
|
||||
formatMessage(level, message, meta = {}) {
|
||||
const timestamp = new Date().toISOString()
|
||||
const metaStr = Object.keys(meta).length > 0 ? JSON.stringify(meta) : ''
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message} ${metaStr}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入日志文件
|
||||
*/
|
||||
writeToFile(level, message, meta) {
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
const logFile = path.join(this.logDir, `${date}.log`)
|
||||
const logMessage = this.formatMessage(level, message, meta)
|
||||
|
||||
fs.appendFile(logFile, logMessage, (err) => {
|
||||
if (err) console.error('写入日志失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录日志
|
||||
*/
|
||||
log(level, message, meta = {}) {
|
||||
if (LOG_LEVELS[level] < this.level) return
|
||||
|
||||
const formattedMessage = this.formatMessage(level, message, meta)
|
||||
|
||||
// 控制台输出
|
||||
switch (level) {
|
||||
case 'error':
|
||||
console.error(formattedMessage.trim())
|
||||
break
|
||||
case 'warn':
|
||||
console.warn(formattedMessage.trim())
|
||||
break
|
||||
default:
|
||||
console.log(formattedMessage.trim())
|
||||
}
|
||||
|
||||
// 文件输出(生产环境)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
this.writeToFile(level, message, meta)
|
||||
}
|
||||
}
|
||||
|
||||
debug(message, meta) {
|
||||
this.log('debug', message, meta)
|
||||
}
|
||||
|
||||
info(message, meta) {
|
||||
this.log('info', message, meta)
|
||||
}
|
||||
|
||||
warn(message, meta) {
|
||||
this.log('warn', message, meta)
|
||||
}
|
||||
|
||||
error(message, meta) {
|
||||
this.log('error', message, meta)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const logger = new Logger(process.env.LOG_LEVEL || 'info')
|
||||
|
||||
module.exports = logger
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 统一响应格式
|
||||
*/
|
||||
class ApiResponse {
|
||||
/**
|
||||
* 成功响应
|
||||
*/
|
||||
static success(res, data = null, message = '操作成功', statusCode = 200) {
|
||||
return res.status(statusCode).json({
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应
|
||||
*/
|
||||
static error(res, error = '操作失败', statusCode = 400, details = null) {
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
error,
|
||||
details,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应
|
||||
*/
|
||||
static paginated(res, items, pagination, message = '查询成功') {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message,
|
||||
data: {
|
||||
items,
|
||||
pagination: {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
total: pagination.total,
|
||||
totalPages: Math.ceil(pagination.total / pagination.limit)
|
||||
}
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 未授权响应
|
||||
*/
|
||||
static unauthorized(res, error = '未授权访问') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁止访问响应
|
||||
*/
|
||||
static forbidden(res, error = '权限不足') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源不存在响应
|
||||
*/
|
||||
static notFound(res, error = '资源不存在') {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器错误响应
|
||||
*/
|
||||
static serverError(res, error = '服务器内部错误') {
|
||||
console.error('Server Error:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ApiResponse
|
||||
@@ -0,0 +1,235 @@
|
||||
const axios = require('axios')
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
/**
|
||||
* 微信小程序 API 工具类
|
||||
*/
|
||||
class WeChatService {
|
||||
constructor() {
|
||||
this.appId = process.env.WX_APPID
|
||||
this.secret = process.env.WX_SECRET
|
||||
this.mchId = process.env.WX_PAY_MCH_ID
|
||||
this.apiKey = process.env.WX_PAY_API_KEY // APIv3 Key
|
||||
this.serialNo = process.env.WX_PAY_SERIAL_NO
|
||||
this.notifyUrl = process.env.WX_PAY_NOTIFY_URL
|
||||
|
||||
// 加载证书
|
||||
try {
|
||||
const certPath = path.join(__dirname, '../../certs/apiclient_cert.pem')
|
||||
const keyPath = path.join(__dirname, '../../certs/apiclient_key.pem')
|
||||
this.privateKey = fs.readFileSync(keyPath)
|
||||
this.certificate = fs.readFileSync(certPath)
|
||||
} catch (error) {
|
||||
console.error('微信支付证书加载失败,请检查 certs 目录:', error.message)
|
||||
}
|
||||
|
||||
// 缓存 access_token
|
||||
this.accessToken = null
|
||||
this.tokenExpireTime = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Access Token
|
||||
*/
|
||||
async getAccessToken() {
|
||||
// 检查缓存
|
||||
if (this.accessToken && Date.now() < this.tokenExpireTime) {
|
||||
return this.accessToken
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
|
||||
params: {
|
||||
grant_type: 'client_credential',
|
||||
appid: this.appId,
|
||||
secret: this.secret
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.errcode) {
|
||||
throw new Error(response.data.errmsg)
|
||||
}
|
||||
|
||||
this.accessToken = response.data.access_token
|
||||
this.tokenExpireTime = Date.now() + (response.data.expires_in - 300) * 1000 // 提前5分钟过期
|
||||
|
||||
return this.accessToken
|
||||
} catch (error) {
|
||||
console.error('获取 Access Token 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 code 换取 openid 和 session_key
|
||||
*/
|
||||
async code2Session(code) {
|
||||
try {
|
||||
const response = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
|
||||
params: {
|
||||
appid: this.appId,
|
||||
secret: this.secret,
|
||||
js_code: code,
|
||||
grant_type: 'authorization_code'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.errcode && response.data.errcode !== 0) {
|
||||
throw new Error(response.data.errmsg)
|
||||
}
|
||||
|
||||
return {
|
||||
openid: response.data.openid,
|
||||
sessionKey: response.data.session_key,
|
||||
unionid: response.data.unionid
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('code2Session 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串
|
||||
*/
|
||||
generateNonceStr(length = 32) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成订单号
|
||||
*/
|
||||
generateOrderNo(prefix = 'WD') {
|
||||
const date = new Date()
|
||||
const dateStr = date.getFullYear().toString() +
|
||||
String(date.getMonth() + 1).padStart(2, '0') +
|
||||
String(date.getDate()).padStart(2, '0')
|
||||
const randomStr = Math.random().toString(36).substr(2, 6).toUpperCase()
|
||||
return `${prefix}${dateStr}${randomStr}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名
|
||||
*/
|
||||
generateSign(method, url, timestamp, nonceStr, body = '') {
|
||||
const signStr = `${method}\n${url}\n${timestamp}\n${nonceStr}\n${body}\n`
|
||||
const sign = crypto.createSign('RSA-SHA256')
|
||||
sign.update(signStr)
|
||||
sign.end()
|
||||
return sign.sign(this.privateKey, 'base64')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付订单(小程序支付 - 真实流程)
|
||||
*/
|
||||
async createPaymentOrder(orderData) {
|
||||
const { openid, orderNo, body, amount } = orderData
|
||||
|
||||
const nonceStr = this.generateNonceStr()
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
|
||||
// 1. 统一下单 (JSAPI)
|
||||
const url = '/v3/pay/transactions/jsapi'
|
||||
const fullUrl = `https://api.mch.weixin.qq.com${url}`
|
||||
|
||||
const orderParams = {
|
||||
appid: this.appId,
|
||||
mchid: this.mchId,
|
||||
description: body,
|
||||
out_trade_no: orderNo,
|
||||
notify_url: this.notifyUrl,
|
||||
amount: {
|
||||
total: Math.round(amount * 100), // 转换为分
|
||||
currency: 'CNY'
|
||||
},
|
||||
payer: {
|
||||
openid: openid
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 注意:实际生产环境需要处理网络请求和签名
|
||||
// const response = await axios.post(fullUrl, orderParams, { ... })
|
||||
// const prepayId = response.data.prepay_id
|
||||
|
||||
// 模拟获取 prepay_id
|
||||
const prepayId = `wx_mock_${Date.now()}`
|
||||
|
||||
// 2. 生成前端调起支付的签名
|
||||
const paySignStr = `${this.appId}\n${timestamp}\n${nonceStr}\nprepay_id=${prepayId}\n`
|
||||
const paySign = crypto.createSign('RSA-SHA256')
|
||||
paySign.update(paySignStr)
|
||||
paySign.end()
|
||||
const signature = paySign.sign(this.privateKey, 'base64')
|
||||
|
||||
return {
|
||||
appId: this.appId,
|
||||
timeStamp: timestamp,
|
||||
nonceStr,
|
||||
package: `prepay_id=${prepayId}`,
|
||||
signType: 'RSA',
|
||||
paySign: signature,
|
||||
orderNo
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('微信支付下单失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
*/
|
||||
async refundOrder(transactionId, outRefundNo, totalAmount, refundAmount, reason = '用户申请退款') {
|
||||
const url = '/v3/refund/domestic/refunds'
|
||||
const fullUrl = `https://api.mch.weixin.qq.com${url}`
|
||||
|
||||
const nonceStr = this.generateNonceStr()
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
|
||||
const refundParams = {
|
||||
transaction_id: transactionId,
|
||||
out_refund_no: outRefundNo,
|
||||
reason: reason,
|
||||
notify_url: this.notifyUrl.replace('/callback', '/refund-callback'), // 假设退款回调地址
|
||||
amount: {
|
||||
refund: Math.round(refundAmount * 100),
|
||||
total: Math.round(totalAmount * 100),
|
||||
currency: 'CNY'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 注意:实际生产环境需要处理网络请求和签名
|
||||
// const sign = this.generateSign('POST', url, timestamp, nonceStr, JSON.stringify(refundParams))
|
||||
// const response = await axios.post(fullUrl, refundParams, { ... })
|
||||
|
||||
console.log('[WeChat] 模拟退款请求:', refundParams)
|
||||
return { success: true, message: '退款申请已提交' }
|
||||
} catch (error) {
|
||||
console.error('微信支付退款失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证支付回调签名
|
||||
*/
|
||||
verifyCallbackSignature(data, sign) {
|
||||
// 实际应使用微信支付 V3 的签名验证方法
|
||||
// 这里仅作示例
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const weChatService = new WeChatService()
|
||||
|
||||
module.exports = weChatService
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 认证 API 集成测试
|
||||
*/
|
||||
|
||||
const request = require('supertest')
|
||||
const mongoose = require('mongoose')
|
||||
const app = require('../../src/index')
|
||||
const User = require('../../src/models/User')
|
||||
const Admin = require('../../src/models/Admin')
|
||||
|
||||
describe('Auth API', () => {
|
||||
beforeAll(async () => {
|
||||
// 连接测试数据库
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
await mongoose.connect(mongoURI)
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// 清理并关闭连接
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
await mongoose.connection.dropDatabase()
|
||||
await mongoose.connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// 每个测试前清空集合
|
||||
await User.deleteMany({})
|
||||
await Admin.deleteMany({})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
test('新用户应该成功注册', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
openid: 'new_user_openid',
|
||||
userInfo: {
|
||||
nickName: '新用户',
|
||||
avatarUrl: 'https://example.com/avatar.jpg'
|
||||
}
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.isNewUser).toBe(true)
|
||||
expect(response.body.data.openid).toBe('new_user_openid')
|
||||
expect(response.body.data.token).toBeDefined()
|
||||
})
|
||||
|
||||
test('老用户应该成功登录', async () => {
|
||||
// 先创建一个用户
|
||||
await User.create({
|
||||
openid: 'existing_user_openid',
|
||||
nickName: '老用户',
|
||||
totalScore: 100
|
||||
})
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
openid: 'existing_user_openid',
|
||||
userInfo: {
|
||||
nickName: '老用户新名字'
|
||||
}
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.isNewUser).toBe(false)
|
||||
expect(response.body.data.openid).toBe('existing_user_openid')
|
||||
})
|
||||
|
||||
test('缺少 openid 应该返回错误', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
userInfo: {
|
||||
nickName: '测试用户'
|
||||
}
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/admin/login', () => {
|
||||
beforeEach(async () => {
|
||||
// 创建测试管理员
|
||||
const admin = new Admin({
|
||||
username: 'testadmin',
|
||||
password: 'password123',
|
||||
role: 'super_admin',
|
||||
status: 'active'
|
||||
})
|
||||
await admin.save()
|
||||
})
|
||||
|
||||
test('管理员应该成功登录', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({
|
||||
username: 'testadmin',
|
||||
password: 'password123'
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.token).toBeDefined()
|
||||
expect(response.body.data.username).toBe('testadmin')
|
||||
expect(response.body.data.role).toBe('super_admin')
|
||||
})
|
||||
|
||||
test('错误的密码应该返回401', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({
|
||||
username: 'testadmin',
|
||||
password: 'wrongpassword'
|
||||
})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
|
||||
test('不存在的用户应该返回401', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({
|
||||
username: 'nonexistent',
|
||||
password: 'password123'
|
||||
})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
|
||||
test('禁用的账户应该返回403', async () => {
|
||||
// 创建禁用的管理员
|
||||
const disabledAdmin = new Admin({
|
||||
username: 'disabled',
|
||||
password: 'password123',
|
||||
role: 'viewer',
|
||||
status: 'inactive'
|
||||
})
|
||||
await disabledAdmin.save()
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({
|
||||
username: 'disabled',
|
||||
password: 'password123'
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
let userToken
|
||||
let testUser
|
||||
|
||||
beforeEach(async () => {
|
||||
// 创建测试用户并获取token
|
||||
testUser = await User.create({
|
||||
openid: 'test_user_openid',
|
||||
nickName: '测试用户',
|
||||
totalScore: 500
|
||||
})
|
||||
|
||||
// 登录获取token
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ openid: 'test_user_openid' })
|
||||
|
||||
userToken = loginResponse.body.data.token
|
||||
})
|
||||
|
||||
test('应该返回当前用户信息', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.openid).toBe('test_user_openid')
|
||||
expect(response.body.data.nickName).toBe('测试用户')
|
||||
})
|
||||
|
||||
test('缺少 token 应该返回401', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
|
||||
test('不存在的用户应该返回401', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', 'Bearer invalid_token')
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 用户 API 集成测试
|
||||
*/
|
||||
|
||||
const request = require('supertest')
|
||||
const mongoose = require('mongoose')
|
||||
const app = require('../../src/index')
|
||||
const User = require('../../src/models/User')
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
describe('User API', () => {
|
||||
let testUser
|
||||
let authToken
|
||||
|
||||
beforeAll(async () => {
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
await mongoose.connect(mongoURI)
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
await mongoose.connection.dropDatabase()
|
||||
await mongoose.connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await User.deleteMany({})
|
||||
|
||||
// 创建测试用户
|
||||
testUser = await User.create({
|
||||
openid: 'test_user_openid',
|
||||
nickName: '测试用户',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
exploreData: {
|
||||
dim1: { bestScore: 100, completed: true },
|
||||
dim2: { bestArea: 200, completed: false }
|
||||
},
|
||||
totalScore: 300,
|
||||
unlockedDims: [1, 2]
|
||||
})
|
||||
|
||||
// 生成认证 Token
|
||||
authToken = jwt.sign(
|
||||
{ userId: testUser._id.toString(), openid: testUser.openid },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
)
|
||||
})
|
||||
|
||||
describe('GET /api/user/progress', () => {
|
||||
test('应该返回用户探索进度', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.exploreData).toBeDefined()
|
||||
expect(response.body.data.exploreData.dim1.bestScore).toBe(100)
|
||||
expect(response.body.data.exploreData.dim2.bestArea).toBe(200)
|
||||
})
|
||||
|
||||
test('不存在的用户应该返回404', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/progress')
|
||||
.set('Authorization', 'Bearer invalid_token')
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/user/progress', () => {
|
||||
test('应该成功保存探索进度', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
dim: 1,
|
||||
score: 150,
|
||||
collectedEggs: 5
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
|
||||
// 验证数据库更新
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.exploreData.dim1.bestScore).toBe(150)
|
||||
expect(updatedUser.exploreData.dim1.collectedEggs).toBe(5)
|
||||
})
|
||||
|
||||
test('新用户应该自动解锁维度', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
dim: 3,
|
||||
score: 100
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.unlockedDims).toContain(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/user/leaderboard', () => {
|
||||
beforeEach(async () => {
|
||||
// 创建多个测试用户
|
||||
await User.create([
|
||||
{ openid: 'user1', nickName: '用户1', totalScore: 500, dim1Score: 200 },
|
||||
{ openid: 'user2', nickName: '用户2', totalScore: 300, dim1Score: 150 },
|
||||
{ openid: 'user3', nickName: '用户3', totalScore: 400, dim1Score: 100 }
|
||||
])
|
||||
})
|
||||
|
||||
test('应该返回总分排行榜', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ limit: 10 })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data).toHaveLength(4) // 包含testUser
|
||||
expect(response.body.data[0].totalScore).toBeGreaterThanOrEqual(
|
||||
response.body.data[1].totalScore
|
||||
)
|
||||
})
|
||||
|
||||
test('应该返回维度排行榜', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ dim: 1, limit: 10 })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.data[0].dim1Score).toBe(200)
|
||||
})
|
||||
|
||||
test('应该限制返回数量', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ limit: 2 })
|
||||
|
||||
expect(response.body.data).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/user/info', () => {
|
||||
test('应该成功更新用户信息', async () => {
|
||||
const response = await request(app)
|
||||
.put('/api/user/info')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
nickName: '新名字',
|
||||
avatarUrl: 'https://example.com/new-avatar.jpg'
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.nickName).toBe('新名字')
|
||||
expect(updatedUser.avatarUrl).toBe('https://example.com/new-avatar.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/user/collect-knowledge', () => {
|
||||
test('应该成功收藏知识', async () => {
|
||||
const knowledgeId = new mongoose.Types.ObjectId()
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/user/collect-knowledge')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ knowledgeId: knowledgeId.toString() })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.collectedKnowledge).toContainEqual(knowledgeId)
|
||||
})
|
||||
|
||||
test('重复收藏应该返回已存在', async () => {
|
||||
const knowledgeId = new mongoose.Types.ObjectId()
|
||||
|
||||
// 第一次收藏
|
||||
await request(app)
|
||||
.post('/api/user/collect-knowledge')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ knowledgeId: knowledgeId.toString() })
|
||||
|
||||
// 第二次收藏
|
||||
const response = await request(app)
|
||||
.post('/api/user/collect-knowledge')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ knowledgeId: knowledgeId.toString() })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Jest 测试设置
|
||||
*/
|
||||
|
||||
// 增加测试超时时间
|
||||
jest.setTimeout(30000)
|
||||
|
||||
// 全局清理
|
||||
afterAll(async () => {
|
||||
// 关闭所有数据库连接
|
||||
const mongoose = require('mongoose')
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
await mongoose.connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
// 抑制控制台日志(测试时)
|
||||
if (process.env.SUPPRESS_LOGS) {
|
||||
global.console = {
|
||||
...console,
|
||||
log: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 用户模型单元测试
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose')
|
||||
const User = require('../../src/models/User')
|
||||
|
||||
describe('User Model', () => {
|
||||
beforeAll(async () => {
|
||||
// 连接测试数据库
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
||||
await mongoose.connect(mongoURI)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// 清理并关闭连接
|
||||
await mongoose.connection.dropDatabase()
|
||||
await mongoose.connection.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// 每个测试前清空users集合
|
||||
await User.deleteMany({})
|
||||
})
|
||||
|
||||
describe('用户创建', () => {
|
||||
test('应该成功创建新用户', async () => {
|
||||
const userData = {
|
||||
openid: 'test_openid_123',
|
||||
nickName: '测试用户',
|
||||
avatarUrl: 'https://example.com/avatar.jpg'
|
||||
}
|
||||
|
||||
const user = new User(userData)
|
||||
const savedUser = await user.save()
|
||||
|
||||
expect(savedUser._id).toBeDefined()
|
||||
expect(savedUser.openid).toBe(userData.openid)
|
||||
expect(savedUser.nickName).toBe(userData.nickName)
|
||||
expect(savedUser.avatarUrl).toBe(userData.avatarUrl)
|
||||
expect(savedUser.unlockedDims).toContain(1)
|
||||
expect(savedUser.totalScore).toBe(0)
|
||||
})
|
||||
|
||||
test('openid 是必需的', async () => {
|
||||
const user = new User({
|
||||
nickName: '测试用户'
|
||||
})
|
||||
|
||||
await expect(user.save()).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('openid 必须唯一', async () => {
|
||||
const userData = {
|
||||
openid: 'unique_openid',
|
||||
nickName: '用户1'
|
||||
}
|
||||
|
||||
await new User(userData).save()
|
||||
|
||||
const duplicateUser = new User({
|
||||
openid: 'unique_openid',
|
||||
nickName: '用户2'
|
||||
})
|
||||
|
||||
await expect(duplicateUser.save()).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('默认昵称应该是"星辰旅行者"', async () => {
|
||||
const user = new User({ openid: 'test_openid' })
|
||||
await user.save()
|
||||
|
||||
expect(user.nickName).toBe('星辰旅行者')
|
||||
})
|
||||
})
|
||||
|
||||
describe('探索数据', () => {
|
||||
test('应该正确计算总分', async () => {
|
||||
const user = new User({
|
||||
openid: 'test_openid',
|
||||
exploreData: {
|
||||
dim1: { bestScore: 100 },
|
||||
dim2: { bestArea: 200 },
|
||||
dim3: { bestScore: 150 }
|
||||
}
|
||||
})
|
||||
|
||||
await user.save()
|
||||
|
||||
expect(user.totalScore).toBe(450)
|
||||
expect(user.dim1Score).toBe(100)
|
||||
expect(user.dim2Score).toBe(200)
|
||||
expect(user.dim3Score).toBe(150)
|
||||
})
|
||||
|
||||
test('未解锁维度分数应为0', async () => {
|
||||
const user = new User({
|
||||
openid: 'test_openid',
|
||||
exploreData: {
|
||||
dim1: { bestScore: 100 }
|
||||
}
|
||||
})
|
||||
|
||||
await user.save()
|
||||
|
||||
expect(user.dim2Score).toBe(0)
|
||||
expect(user.dim3Score).toBe(0)
|
||||
expect(user.dim4Score).toBe(0)
|
||||
expect(user.dim5Score).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('虚拟字段', () => {
|
||||
test('isNew 应该对新用户返回 true', async () => {
|
||||
const user = new User({ openid: 'test_openid' })
|
||||
await user.save()
|
||||
|
||||
expect(user.isNew).toBe(true)
|
||||
})
|
||||
|
||||
test('isNew 应该对老用户返回 false', async () => {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 2)
|
||||
|
||||
const user = new User({
|
||||
openid: 'test_openid',
|
||||
createdAt: yesterday
|
||||
})
|
||||
await user.save()
|
||||
|
||||
expect(user.isNew).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('静态方法', () => {
|
||||
test('getLeaderboard 应该返回排行榜', async () => {
|
||||
// 创建测试用户
|
||||
await User.create([
|
||||
{ openid: 'user1', totalScore: 300, dim1Score: 100 },
|
||||
{ openid: 'user2', totalScore: 500, dim1Score: 200 },
|
||||
{ openid: 'user3', totalScore: 400, dim1Score: 150 }
|
||||
])
|
||||
|
||||
const leaderboard = await User.getLeaderboard(null, 10)
|
||||
|
||||
expect(leaderboard).toHaveLength(3)
|
||||
expect(leaderboard[0].totalScore).toBe(500)
|
||||
expect(leaderboard[1].totalScore).toBe(400)
|
||||
expect(leaderboard[2].totalScore).toBe(300)
|
||||
})
|
||||
|
||||
test('getLeaderboard 应该支持按维度排序', async () => {
|
||||
await User.create([
|
||||
{ openid: 'user1', dim1Score: 100, totalScore: 100 },
|
||||
{ openid: 'user2', dim1Score: 200, totalScore: 200 },
|
||||
{ openid: 'user3', dim1Score: 150, totalScore: 150 }
|
||||
])
|
||||
|
||||
const leaderboard = await User.getLeaderboard(1, 10)
|
||||
|
||||
expect(leaderboard[0].dim1Score).toBe(200)
|
||||
expect(leaderboard[1].dim1Score).toBe(150)
|
||||
expect(leaderboard[2].dim1Score).toBe(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,856 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然AI维度 - UI 设计稿 v2</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #050508;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* === Phone Frame === */
|
||||
.phone {
|
||||
width: 375px;
|
||||
background: radial-gradient(ellipse at 50% 0%, #0f0a1a 0%, #08080f 60%, #050508 100%);
|
||||
border-radius: 44px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 0 80px rgba(100, 50, 200, 0.08),
|
||||
0 20px 60px rgba(0,0,0,0.9);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
position: relative;
|
||||
}
|
||||
/* Subtle cosmic glow */
|
||||
.phone::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -120px;
|
||||
left: -80px;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: radial-gradient(circle, rgba(124,77,255,0.06) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.phone::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -80px;
|
||||
right: -60px;
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: radial-gradient(circle, rgba(0,188,212,0.04) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.phone-content { padding: 0 20px; position: relative; z-index: 1; }
|
||||
|
||||
/* === Status Bar === */
|
||||
.status-bar { display: flex; justify-content: space-between; padding: 14px 20px 10px; color: rgba(255,255,255,0.4); font-size: 12px; font-weight: 600; }
|
||||
.status-bar .time { color: rgba(255,255,255,0.7); }
|
||||
.status-icons { display: flex; gap: 6px; align-items: center; }
|
||||
.status-icons span { font-size: 12px; }
|
||||
|
||||
/* === Header === */
|
||||
.header { display: flex; justify-content: space-between; align-items: center; padding: 6px 0 20px; }
|
||||
.header .brand { display: flex; align-items: center; gap: 10px; }
|
||||
.header .brand-icon {
|
||||
width: 36px; height: 36px; border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2));
|
||||
border: 1px solid rgba(124,77,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 0 20px rgba(124,77,255,0.1);
|
||||
}
|
||||
.header .brand-text h1 { font-size: 20px; color: #fff; font-weight: 700; letter-spacing: 1px; }
|
||||
.header .brand-text .subtitle { font-size: 10px; color: rgba(255,255,255,0.3); letter-spacing: 2px; text-transform: uppercase; }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
.header-actions .icon {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: rgba(255,255,255,0.5); font-size: 15px; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.header-actions .icon:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.12); }
|
||||
|
||||
/* === Greeting === */
|
||||
.greeting { margin-bottom: 20px; }
|
||||
.greeting h2 { color: #fff; font-size: 26px; font-weight: 700; }
|
||||
.greeting h2 span { background: linear-gradient(135deg, #b388ff, #7c4dff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.greeting p { color: rgba(255,255,255,0.3); font-size: 13px; margin-top: 4px; }
|
||||
|
||||
/* === Search Bar === */
|
||||
.search-bar {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.search-bar span { color: rgba(255,255,255,0.2); font-size: 14px; }
|
||||
.search-bar .icon { color: rgba(255,255,255,0.2); }
|
||||
|
||||
/* === Section Header === */
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
|
||||
.section-header h2 { color: #fff; font-size: 17px; font-weight: 600; letter-spacing: 0.5px; }
|
||||
.section-header .more { color: rgba(255,255,255,0.25); font-size: 12px; cursor: pointer; transition: color 0.2s; }
|
||||
.section-header .more:hover { color: rgba(255,255,255,0.5); }
|
||||
|
||||
/* === Dimensions Grid === */
|
||||
.dim-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 28px; }
|
||||
.dim-card {
|
||||
border-radius: 18px;
|
||||
padding: 20px 16px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.dim-card:hover {
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.dim-card .dim-glow {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
pointer-events: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.dim-card .dim-icon {
|
||||
width: 42px; height: 42px; border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; margin-bottom: 14px;
|
||||
position: relative;
|
||||
}
|
||||
.dim-card .dim-name { color: #fff; font-size: 15px; font-weight: 600; margin-bottom: 4px; letter-spacing: 0.5px; }
|
||||
.dim-card .dim-desc { color: rgba(255,255,255,0.3); font-size: 11px; line-height: 1.5; }
|
||||
.dim-card .dim-count { color: rgba(255,255,255,0.15); font-size: 10px; margin-top: 10px; }
|
||||
.dim-card.full { grid-column: 1 / -1; }
|
||||
|
||||
/* Color variants with glassmorphism backgrounds */
|
||||
.dim-card.origin { background: linear-gradient(135deg, rgba(124,77,255,0.08), rgba(124,77,255,0.02)); }
|
||||
.dim-icon.origin { background: linear-gradient(135deg, rgba(124,77,255,0.25), rgba(124,77,255,0.1)); color: #b388ff; box-shadow: 0 0 20px rgba(124,77,255,0.1); }
|
||||
|
||||
.dim-card.dev { background: linear-gradient(135deg, rgba(0,188,212,0.08), rgba(0,188,212,0.02)); }
|
||||
.dim-icon.dev { background: linear-gradient(135deg, rgba(0,188,212,0.25), rgba(0,188,212,0.1)); color: #4dd0e1; box-shadow: 0 0 20px rgba(0,188,212,0.1); }
|
||||
|
||||
.dim-card.current { background: linear-gradient(135deg, rgba(0,200,150,0.08), rgba(0,200,150,0.02)); }
|
||||
.dim-icon.current { background: linear-gradient(135deg, rgba(0,200,150,0.25), rgba(0,200,150,0.1)); color: #4db6ac; box-shadow: 0 0 20px rgba(0,200,150,0.1); }
|
||||
|
||||
.dim-card.learn { background: linear-gradient(135deg, rgba(255,193,7,0.08), rgba(255,193,7,0.02)); }
|
||||
.dim-icon.learn { background: linear-gradient(135deg, rgba(255,193,7,0.25), rgba(255,193,7,0.1)); color: #ffd54f; box-shadow: 0 0 20px rgba(255,193,7,0.1); }
|
||||
|
||||
.dim-card.trend { background: linear-gradient(135deg, rgba(239,83,80,0.08), rgba(239,83,80,0.02)); }
|
||||
.dim-icon.trend { background: linear-gradient(135deg, rgba(239,83,80,0.25), rgba(239,83,80,0.1)); color: #ef5350; box-shadow: 0 0 20px rgba(239,83,80,0.1); }
|
||||
|
||||
/* === News Cards === */
|
||||
.news-card {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.news-card:hover { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.1); }
|
||||
.news-card .news-tag { display: inline-block; font-size: 10px; padding: 2px 10px; border-radius: 6px; margin-bottom: 8px; font-weight: 500; }
|
||||
.news-card .news-tag.hot { background: rgba(239,83,80,0.15); color: #ef5350; }
|
||||
.news-card .news-tag.new { background: rgba(0,188,212,0.15); color: #4dd0e1; }
|
||||
.news-card .news-tag.pick { background: rgba(124,77,255,0.15); color: #b388ff; }
|
||||
.news-card .news-title { color: rgba(255,255,255,0.85); font-size: 13px; line-height: 1.5; margin-bottom: 8px; font-weight: 500; }
|
||||
.news-card .news-meta { color: rgba(255,255,255,0.2); font-size: 10px; display: flex; gap: 16px; }
|
||||
|
||||
/* === Bottom Nav === */
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 8px 0 22px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
margin-top: 12px;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
.nav-item {
|
||||
text-align: center;
|
||||
color: rgba(255,255,255,0.2);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.nav-item .nav-icon { font-size: 20px; margin-bottom: 2px; display: block; }
|
||||
.nav-item.active { color: #b388ff; }
|
||||
.nav-item.active .nav-icon { color: #b388ff; }
|
||||
.nav-item.active { background: rgba(124,77,255,0.08); }
|
||||
|
||||
/* ============ PAGE 2: Knowledge List ============ */
|
||||
.back-btn {
|
||||
color: rgba(255,255,255,0.4);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 10px 0 14px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.back-btn:hover { color: rgba(255,255,255,0.7); }
|
||||
|
||||
.dim-banner {
|
||||
border-radius: 18px;
|
||||
padding: 24px 20px;
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.1), rgba(0,0,0,0.3));
|
||||
border: 1px solid rgba(124,77,255,0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dim-banner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60px;
|
||||
right: -40px;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: radial-gradient(circle, rgba(124,77,255,0.08) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.dim-banner h2 { color: #fff; font-size: 22px; margin-bottom: 6px; }
|
||||
.dim-banner p { color: rgba(255,255,255,0.4); font-size: 12px; line-height: 1.6; }
|
||||
.dim-banner .progress-track { height: 3px; background: rgba(255,255,255,0.06); border-radius: 2px; margin-top: 16px; overflow: hidden; }
|
||||
.dim-banner .progress-track .fill { height: 100%; width: 35%; background: linear-gradient(90deg, #b388ff, #7c4dff); border-radius: 2px; }
|
||||
.dim-banner .progress-label { color: rgba(255,255,255,0.2); font-size: 11px; margin-top: 8px; }
|
||||
|
||||
.knowledge-item {
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.knowledge-item:hover { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.1); }
|
||||
.knowledge-item .k-icon {
|
||||
width: 44px; height: 44px; border-radius: 12px;
|
||||
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.knowledge-item .k-info { flex: 1; }
|
||||
.knowledge-item .k-title { color: rgba(255,255,255,0.85); font-size: 14px; font-weight: 500; margin-bottom: 4px; }
|
||||
.knowledge-item .k-desc { color: rgba(255,255,255,0.3); font-size: 11px; line-height: 1.5; margin-bottom: 8px; }
|
||||
.knowledge-item .k-meta { display: flex; gap: 12px; color: rgba(255,255,255,0.15); font-size: 10px; align-items: center; }
|
||||
.knowledge-item .k-badge { font-size: 10px; padding: 2px 10px; border-radius: 6px; font-weight: 500; }
|
||||
.k-badge.free { background: rgba(76,175,80,0.12); color: #66bb6a; }
|
||||
.k-badge.pro { background: rgba(255,152,0,0.12); color: #ffa726; }
|
||||
|
||||
/* ============ PAGE 3: AI Chat ============ */
|
||||
.chat-header { display: flex; align-items: center; gap: 12px; padding: 10px 0 20px; }
|
||||
.chat-avatar {
|
||||
width: 42px; height: 42px; border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2));
|
||||
border: 1px solid rgba(124,77,255,0.15);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 18px;
|
||||
box-shadow: 0 0 20px rgba(124,77,255,0.1);
|
||||
}
|
||||
.chat-header .chat-info h3 { color: #fff; font-size: 16px; font-weight: 600; }
|
||||
.chat-header .chat-info span { color: rgba(255,255,255,0.3); font-size: 11px; }
|
||||
.chat-quota {
|
||||
margin-left: auto;
|
||||
background: rgba(124,77,255,0.1);
|
||||
border: 1px solid rgba(124,77,255,0.15);
|
||||
border-radius: 20px;
|
||||
padding: 6px 14px;
|
||||
color: #b388ff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-msg { display: flex; gap: 10px; margin-bottom: 16px; }
|
||||
.chat-msg.ai { }
|
||||
.chat-msg.user { flex-direction: row-reverse; }
|
||||
.chat-msg .bubble {
|
||||
max-width: 82%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.chat-msg.ai .bubble {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
color: rgba(255,255,255,0.8);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.chat-msg.user .bubble {
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(124,77,255,0.15));
|
||||
border: 1px solid rgba(124,77,255,0.15);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.chat-msg .time { font-size: 10px; color: rgba(255,255,255,0.15); padding-top: 4px; }
|
||||
.chat-msg.ai .time { padding-left: 52px; }
|
||||
.chat-msg.user .time { text-align: right; }
|
||||
|
||||
.chat-input {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 14px;
|
||||
}
|
||||
.chat-input input::placeholder { color: rgba(255,255,255,0.2); }
|
||||
.chat-input .send-btn {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; cursor: pointer;
|
||||
box-shadow: 0 0 20px rgba(124,77,255,0.2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chat-input .send-btn:hover { transform: scale(1.05); box-shadow: 0 0 30px rgba(124,77,255,0.3); }
|
||||
|
||||
/* ============ PAGE 4: Profile ============ */
|
||||
.profile-card { text-align: center; padding: 20px 0 24px; }
|
||||
.profile-avatar {
|
||||
width: 72px; height: 72px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.15));
|
||||
border: 2px solid rgba(124,77,255,0.2);
|
||||
margin: 0 auto 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 32px;
|
||||
box-shadow: 0 0 40px rgba(124,77,255,0.1);
|
||||
}
|
||||
.profile-card h3 { color: #fff; font-size: 18px; font-weight: 600; }
|
||||
.profile-card .level { color: rgba(255,255,255,0.3); font-size: 12px; margin-top: 4px; }
|
||||
|
||||
.plan-card {
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.08), rgba(0,0,0,0.2));
|
||||
border: 1px solid rgba(124,77,255,0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.plan-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: -20px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: radial-gradient(circle, rgba(124,77,255,0.06) 0%, transparent 70%);
|
||||
}
|
||||
.plan-card .plan-info h3 { color: #fff; font-size: 16px; font-weight: 600; }
|
||||
.plan-card .plan-info p { color: rgba(255,255,255,0.3); font-size: 12px; margin-top: 4px; }
|
||||
.plan-card .plan-btn {
|
||||
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
||||
border: none;
|
||||
padding: 10px 22px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 0 20px rgba(124,77,255,0.2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.plan-card .plan-btn:hover { transform: scale(1.02); box-shadow: 0 0 30px rgba(124,77,255,0.3); }
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.menu-item:hover { padding-left: 4px; }
|
||||
.menu-item .m-icon {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.menu-item .m-label { color: rgba(255,255,255,0.7); font-size: 14px; flex: 1; }
|
||||
.menu-item .m-arrow { color: rgba(255,255,255,0.15); font-size: 14px; }
|
||||
.menu-item .m-badge {
|
||||
font-size: 10px; padding: 2px 10px; border-radius: 10px;
|
||||
background: rgba(239,83,80,0.12); color: #ef5350;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ============ PAGE 5: Trend Feed ============ */
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: 18px; overflow-x: auto; padding-bottom: 4px; }
|
||||
.filter-bar::-webkit-scrollbar { display: none; }
|
||||
.filter-tag {
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.filter-tag.active { background: rgba(124,77,255,0.2); color: #b388ff; border-color: rgba(124,77,255,0.2); }
|
||||
.filter-tag:not(.active) { background: rgba(255,255,255,0.03); color: rgba(255,255,255,0.3); }
|
||||
.filter-tag:hover { border-color: rgba(255,255,255,0.15); }
|
||||
|
||||
.trend-card {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
.trend-card:hover { background: rgba(255,255,255,0.05); }
|
||||
.trend-card .trend-marker {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.trend-card .trend-content { padding-left: 14px; }
|
||||
.trend-card .trend-tag { display: inline-block; font-size: 10px; padding: 2px 10px; border-radius: 6px; margin-bottom: 8px; font-weight: 500; }
|
||||
.trend-card .trend-title { color: rgba(255,255,255,0.85); font-size: 13px; line-height: 1.5; margin-bottom: 6px; font-weight: 500; }
|
||||
.trend-card .trend-source { color: rgba(255,255,255,0.2); font-size: 10px; }
|
||||
|
||||
/* ============ Common ============ */
|
||||
::-webkit-scrollbar { display: none; }
|
||||
.label { color: rgba(255,255,255,0.2); font-size: 11px; text-align: center; padding: 12px 0; }
|
||||
.screen-label { text-align: center; color: rgba(255,255,255,0.15); font-size: 11px; padding: 16px 0 4px; letter-spacing: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============ SCREEN 1: HOME ============ -->
|
||||
<div class="phone">
|
||||
<div class="screen-label">首页 — 5 维度入口 + 今日快讯</div>
|
||||
<div class="status-bar">
|
||||
<span class="time">9:41</span>
|
||||
<div class="status-icons"><span>📶</span><span>📶</span><span>🔋</span></div>
|
||||
</div>
|
||||
<div class="phone-content">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="brand">
|
||||
<div class="brand-icon">✦</div>
|
||||
<div class="brand-text">
|
||||
<h1>AI 维度</h1>
|
||||
<div class="subtitle">宇之然 · 探索</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="icon">🔔</div>
|
||||
<div class="icon">👤</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Greeting -->
|
||||
<div class="greeting">
|
||||
<h2>探索 <span>人工智能</span></h2>
|
||||
<p>从 5 个维度,系统理解 AI 的过去、现在与未来</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-bar">
|
||||
<span class="icon">🔍</span>
|
||||
<span>搜索 AI 知识...</span>
|
||||
</div>
|
||||
|
||||
<!-- 5 Dimensions - Glassmorphism Grid -->
|
||||
<div class="section-header">
|
||||
<h2>✦ 五个维度</h2>
|
||||
</div>
|
||||
<div class="dim-grid">
|
||||
<div class="dim-card origin">
|
||||
<div class="dim-icon origin">🌅</div>
|
||||
<div class="dim-name">AI 起源</div>
|
||||
<div class="dim-desc">从图灵到达特茅斯,追溯 70 年</div>
|
||||
<div class="dim-count">12 篇 · 4 个问答</div>
|
||||
</div>
|
||||
<div class="dim-card dev">
|
||||
<div class="dim-icon dev">📈</div>
|
||||
<div class="dim-name">AI 发展</div>
|
||||
<div class="dim-desc">深度学习·Transformer·MoE</div>
|
||||
<div class="dim-count">18 篇 · 6 个问答</div>
|
||||
</div>
|
||||
<div class="dim-card current">
|
||||
<div class="dim-icon current">🌐</div>
|
||||
<div class="dim-name">AI 当前</div>
|
||||
<div class="dim-desc">大模型格局·Agent·具身智能</div>
|
||||
<div class="dim-count">15 篇 · 5 个问答</div>
|
||||
</div>
|
||||
<div class="dim-card learn">
|
||||
<div class="dim-icon learn">🛠</div>
|
||||
<div class="dim-name">AI 学习</div>
|
||||
<div class="dim-desc">提示词·RAG·微调·工具链</div>
|
||||
<div class="dim-count">20 篇 · 8 个问答</div>
|
||||
</div>
|
||||
<div class="dim-card trend full">
|
||||
<div class="dim-icon trend">🔥</div>
|
||||
<div class="dim-name">AI 趋势</div>
|
||||
<div class="dim-desc">每日资讯 · 产品动态 · 论文快报</div>
|
||||
<div class="dim-count">今日已更新 8 条</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's News -->
|
||||
<div class="section-header">
|
||||
<h2>📡 今日快讯</h2>
|
||||
<span class="more">查看全部 →</span>
|
||||
</div>
|
||||
<div class="news-card">
|
||||
<span class="news-tag hot">🔥 热门</span>
|
||||
<div class="news-title">DeepSeek 被曝自研推理芯片,降低对英伟达依赖</div>
|
||||
<div class="news-meta"><span>量子位</span><span>2 小时前</span></div>
|
||||
</div>
|
||||
<div class="news-card">
|
||||
<span class="news-tag new">🚀 最新</span>
|
||||
<div class="news-title">蚂蚁灵波开源 LingBot-World 2.0,世界模型首次实现小时级实时生成</div>
|
||||
<div class="news-meta"><span>量子位</span><span>4 小时前</span></div>
|
||||
</div>
|
||||
<div class="news-card">
|
||||
<span class="news-tag pick">📌 精选</span>
|
||||
<div class="news-title">阿里获 ACL 2026 最佳资源论文奖,揭示 Agent 结构性缺陷</div>
|
||||
<div class="news-meta"><span>机器之心</span><span>6 小时前</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Nav -->
|
||||
<div class="bottom-nav">
|
||||
<div class="nav-item active"><span class="nav-icon">✦</span>探索</div>
|
||||
<div class="nav-item"><span class="nav-icon">💬</span>问答</div>
|
||||
<div class="nav-item"><span class="nav-icon">📋</span>路径</div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span>我的</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SCREEN 2: DIMENSION DETAIL ============ -->
|
||||
<div class="phone">
|
||||
<div class="screen-label">维度详情 — AI 起源内容列表</div>
|
||||
<div class="status-bar">
|
||||
<span class="time">9:41</span>
|
||||
<div class="status-icons"><span>📶</span><span>📶</span><span>🔋</span></div>
|
||||
</div>
|
||||
<div class="phone-content">
|
||||
<!-- Back -->
|
||||
<div class="back-btn">← 返回</div>
|
||||
|
||||
<!-- Banner -->
|
||||
<div class="dim-banner">
|
||||
<h2>🌅 AI 起源</h2>
|
||||
<p>从 1950 年图灵测试到 2026 年通用人工智能,追溯人工智能 70 年演进之路,理解每一次技术跃迁背后的思想革命</p>
|
||||
<div class="progress-track"><div class="fill"></div></div>
|
||||
<div class="progress-label">探索进度 35% · 已读 4/12 篇</div>
|
||||
</div>
|
||||
|
||||
<!-- Knowledge List -->
|
||||
<div class="knowledge-item">
|
||||
<div class="k-icon" style="background:rgba(124,77,255,0.1);color:#b388ff;border-color:rgba(124,77,255,0.1);">📜</div>
|
||||
<div class="k-info">
|
||||
<div class="k-title">图灵测试:人工智能的哲学起点</div>
|
||||
<div class="k-desc">1950 年,艾伦·图灵提出"机器能思考吗?"</div>
|
||||
<div class="k-meta">
|
||||
<span>📖 5 分钟</span>
|
||||
<span>⭐ 已收藏</span>
|
||||
<span class="k-badge free">免费</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="knowledge-item">
|
||||
<div class="k-icon" style="background:rgba(0,188,212,0.1);color:#4dd0e1;border-color:rgba(0,188,212,0.1);">🏛</div>
|
||||
<div class="k-info">
|
||||
<div class="k-title">达特茅斯会议:AI 的诞生时刻</div>
|
||||
<div class="k-desc">1956 年,一群科学家正式命名了"人工智能"</div>
|
||||
<div class="k-meta">
|
||||
<span>📖 8 分钟</span>
|
||||
<span>🔖 12 人收藏</span>
|
||||
<span class="k-badge free">免费</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="knowledge-item">
|
||||
<div class="k-icon" style="background:rgba(255,193,7,0.1);color:#ffd54f;border-color:rgba(255,193,7,0.1);">♟</div>
|
||||
<div class="k-info">
|
||||
<div class="k-title">深蓝 vs 卡斯帕罗夫:AI 的第一次胜利</div>
|
||||
<div class="k-desc">1997 年,IBM 深蓝首次击败国际象棋世界冠军</div>
|
||||
<div class="k-meta">
|
||||
<span>📖 6 分钟</span>
|
||||
<span>🔖 8 人收藏</span>
|
||||
<span class="k-badge free">免费</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="knowledge-item">
|
||||
<div class="k-icon" style="background:rgba(0,200,150,0.1);color:#4db6ac;border-color:rgba(0,200,150,0.1);">🧠</div>
|
||||
<div class="k-info">
|
||||
<div class="k-title">AlphaGo 与深度学习革命</div>
|
||||
<div class="k-desc">2016 年,AI 攻克围棋——人类智力最后的堡垒</div>
|
||||
<div class="k-meta">
|
||||
<span>📖 10 分钟</span>
|
||||
<span>🔖 15 人收藏</span>
|
||||
<span class="k-badge pro">Pro</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="knowledge-item">
|
||||
<div class="k-icon" style="background:rgba(239,83,80,0.1);color:#ef5350;border-color:rgba(239,83,80,0.1);">⚡</div>
|
||||
<div class="k-info">
|
||||
<div class="k-title">从 GPT 到 AGI:大模型时代</div>
|
||||
<div class="k-desc">2018-2026,Transformer 如何改变一切</div>
|
||||
<div class="k-meta">
|
||||
<span>📖 12 分钟</span>
|
||||
<span>🔖 20 人收藏</span>
|
||||
<span class="k-badge pro">Pro</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SCREEN 3: AI CHAT ============ -->
|
||||
<div class="phone">
|
||||
<div class="screen-label">AI 问答 — 沉浸式对话</div>
|
||||
<div class="status-bar">
|
||||
<span class="time">9:41</span>
|
||||
<div class="status-icons"><span>📶</span><span>📶</span><span>🔋</span></div>
|
||||
</div>
|
||||
<div class="phone-content">
|
||||
<!-- Chat Header -->
|
||||
<div class="chat-header">
|
||||
<div class="chat-avatar">∞</div>
|
||||
<div class="chat-info">
|
||||
<h3>AI 助手</h3>
|
||||
<span>DeepSeek V4 · 在线</span>
|
||||
</div>
|
||||
<div class="chat-quota">剩余 5 次</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="chat-msg ai">
|
||||
<div class="chat-avatar" style="width:32px;height:32px;font-size:14px;border-radius:10px;flex-shrink:0;">∞</div>
|
||||
<div>
|
||||
<div class="bubble">你好!我是 AI 维度助手,你可以问我任何关于人工智能的问题,比如:</div>
|
||||
<div class="time">AI 助手</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-msg ai">
|
||||
<div class="chat-avatar" style="width:32px;height:32px;font-size:14px;border-radius:10px;flex-shrink:0;visibility:hidden;">∞</div>
|
||||
<div>
|
||||
<div class="bubble">💡 "Transformer 的核心原理是什么?"<br>💡 "RAG 和微调有什么区别?"<br>💡 "2026 年最值得关注的 AI 趋势?"</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-msg user">
|
||||
<div>
|
||||
<div class="bubble">Transformer 的核心原理是什么?</div>
|
||||
<div class="time">刚刚</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-msg ai">
|
||||
<div class="chat-avatar" style="width:32px;height:32px;font-size:14px;border-radius:10px;flex-shrink:0;">∞</div>
|
||||
<div>
|
||||
<div class="bubble">Transformer 的核心是 **自注意力机制(Self-Attention)**。它让模型在处理每个词时,能同时关注句子中所有词的关系,而不是像 RNN 那样逐个处理。</div>
|
||||
<div class="time">AI 助手</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-msg ai">
|
||||
<div class="chat-avatar" style="width:32px;height:32px;font-size:14px;border-radius:10px;flex-shrink:0;visibility:hidden;">∞</div>
|
||||
<div>
|
||||
<div class="bubble">简单说:Transformer = 自注意力 + 多头机制 + 位置编码。2017 年由 Google 提出后,直接催生了 GPT、BERT 等所有现代大模型。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="chat-input">
|
||||
<span style="color:rgba(255,255,255,0.2);cursor:pointer;">➕</span>
|
||||
<input type="text" placeholder="输入你的 AI 问题..." />
|
||||
<div class="send-btn">➤</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Nav -->
|
||||
<div class="bottom-nav" style="margin-top:12px;">
|
||||
<div class="nav-item"><span class="nav-icon">✦</span>探索</div>
|
||||
<div class="nav-item active"><span class="nav-icon">💬</span>问答</div>
|
||||
<div class="nav-item"><span class="nav-icon">📋</span>路径</div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span>我的</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SCREEN 4: PROFILE ============ -->
|
||||
<div class="phone">
|
||||
<div class="screen-label">个人中心 — 进度与订阅</div>
|
||||
<div class="status-bar">
|
||||
<span class="time">9:41</span>
|
||||
<div class="status-icons"><span>📶</span><span>📶</span><span>🔋</span></div>
|
||||
</div>
|
||||
<div class="phone-content">
|
||||
<!-- Profile -->
|
||||
<div class="profile-card">
|
||||
<div class="profile-avatar">🧑</div>
|
||||
<h3>星辰旅者</h3>
|
||||
<div class="level">Lv.5 · 维度学徒 · 已探索 12 篇文章</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan -->
|
||||
<div class="plan-card">
|
||||
<div class="plan-info">
|
||||
<h3>✦ 免费版</h3>
|
||||
<p>每日 5 次 AI 问答 · 解锁全部知识</p>
|
||||
</div>
|
||||
<div class="plan-btn">升级 Pro</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
<div class="menu-item">
|
||||
<div class="m-icon" style="background:rgba(124,77,255,0.1);color:#b388ff;border-color:rgba(124,77,255,0.1);">📚</div>
|
||||
<span class="m-label">我的收藏</span>
|
||||
<span class="m-arrow">→</span>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<div class="m-icon" style="background:rgba(0,188,212,0.1);color:#4dd0e1;border-color:rgba(0,188,212,0.1);">📊</div>
|
||||
<span class="m-label">学习进度</span>
|
||||
<span class="m-arrow">→</span>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<div class="m-icon" style="background:rgba(0,200,150,0.1);color:#4db6ac;border-color:rgba(0,200,150,0.1);">🏆</div>
|
||||
<span class="m-label">成就</span>
|
||||
<span class="m-badge">3 项未解锁</span>
|
||||
<span class="m-arrow">→</span>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<div class="m-icon" style="background:rgba(255,193,7,0.1);color:#ffd54f;border-color:rgba(255,193,7,0.1);">📅</div>
|
||||
<span class="m-label">每日签到</span>
|
||||
<span class="m-arrow">→</span>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<div class="m-icon" style="background:rgba(239,83,80,0.1);color:#ef5350;border-color:rgba(239,83,80,0.1);">⚙️</div>
|
||||
<span class="m-label">设置</span>
|
||||
<span class="m-arrow">→</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SCREEN 5: TREND FEED ============ -->
|
||||
<div class="phone">
|
||||
<div class="screen-label">AI 趋势 — 资讯流</div>
|
||||
<div class="status-bar">
|
||||
<span class="time">9:41</span>
|
||||
<div class="status-icons"><span>📶</span><span>📶</span><span>🔋</span></div>
|
||||
</div>
|
||||
<div class="phone-content">
|
||||
<div class="back-btn">← 返回</div>
|
||||
<div class="section-header" style="margin-bottom:2px;">
|
||||
<h2>🔥 AI 趋势</h2>
|
||||
<span class="more">订阅</span>
|
||||
</div>
|
||||
<p style="color:rgba(255,255,255,0.3);font-size:12px;margin-bottom:16px;">每日追踪 AI 前沿动态,不错过每一个重要时刻</p>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="filter-bar">
|
||||
<span class="filter-tag active">全部</span>
|
||||
<span class="filter-tag">🚀 产品</span>
|
||||
<span class="filter-tag">📄 论文</span>
|
||||
<span class="filter-tag">🔧 工具</span>
|
||||
<span class="filter-tag">🏢 公司</span>
|
||||
</div>
|
||||
|
||||
<!-- Trend Cards -->
|
||||
<div class="trend-card" style="border:1px solid rgba(239,83,80,0.08);">
|
||||
<div class="trend-marker" style="background:linear-gradient(180deg, #ef5350, #ff8a80);"></div>
|
||||
<div class="trend-content">
|
||||
<span class="trend-tag" style="background:rgba(239,83,80,0.12);color:#ef5350;">🔥 热门</span>
|
||||
<div class="trend-title">DeepSeek 秘密造芯:推理芯片项目已启动一年</div>
|
||||
<div class="trend-source">路透社 · 2026-07-10</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-card" style="border:1px solid rgba(0,188,212,0.08);">
|
||||
<div class="trend-marker" style="background:linear-gradient(180deg, #4dd0e1, #80deea);"></div>
|
||||
<div class="trend-content">
|
||||
<span class="trend-tag" style="background:rgba(0,188,212,0.12);color:#4dd0e1;">🚀 产品</span>
|
||||
<div class="trend-title">蚂蚁灵波开源 LingBot-World 2.0,世界模型首次实现小时级实时生成</div>
|
||||
<div class="trend-source">量子位 · 2026-07-09</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-card" style="border:1px solid rgba(0,200,150,0.08);">
|
||||
<div class="trend-marker" style="background:linear-gradient(180deg, #4db6ac, #80cbc4);"></div>
|
||||
<div class="trend-content">
|
||||
<span class="trend-tag" style="background:rgba(0,200,150,0.12);color:#4db6ac;">📄 论文</span>
|
||||
<div class="trend-title">阿里获 ACL 2026 最佳资源论文奖,揭示 Agent 结构性缺陷</div>
|
||||
<div class="trend-source">机器之心 · 2026-07-09</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-card" style="border:1px solid rgba(255,193,7,0.08);">
|
||||
<div class="trend-marker" style="background:linear-gradient(180deg, #ffd54f, #ffe082);"></div>
|
||||
<div class="trend-content">
|
||||
<span class="trend-tag" style="background:rgba(255,193,7,0.12);color:#ffd54f;">🔧 工具</span>
|
||||
<div class="trend-title">腾讯混元 Hy3 正式上线,Agent 任务解决率跃升至 90%</div>
|
||||
<div class="trend-source">腾讯云 · 2026-07-09</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-card" style="border:1px solid rgba(124,77,255,0.08);">
|
||||
<div class="trend-marker" style="background:linear-gradient(180deg, #b388ff, #d1c4e9);"></div>
|
||||
<div class="trend-content">
|
||||
<span class="trend-tag" style="background:rgba(124,77,255,0.12);color:#b388ff;">📌 精选</span>
|
||||
<div class="trend-title">WAIC 2026 倒计时:7月17日上海开幕,1100+企业参展</div>
|
||||
<div class="trend-source">量子位 · 2026-07-09</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="label">宇之然AI维度 · 2026 Design v2 · 暗色宇宙玻璃拟态</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
# 后端 API 评估与改造计划
|
||||
|
||||
## 现有 API 与新前端映射
|
||||
|
||||
### ✅ 可直接复用
|
||||
|
||||
| 前端功能 | 后端 API | 改造量 |
|
||||
|---------|---------|--------|
|
||||
| 微信登录 | `POST /api/auth/login` | ✅ 零改动 |
|
||||
| H5 注册/登录 | `POST /api/auth/user/register` / `POST /api/auth/user/login` | ✅ 零改动 |
|
||||
| 获取用户信息 | `GET /api/auth/me` | ✅ 零改动 |
|
||||
| 知识列表(按维度) | `GET /api/knowledge?dim=1&category=concept` | ✅ 零改动,改数据即可 |
|
||||
| 知识详情 | `GET /api/knowledge/:id` | ✅ 零改动 |
|
||||
| 收藏/取消收藏 | `POST /api/knowledge/:id/collect` / `DELETE /api/knowledge/collect/:id` | ✅ 零改动 |
|
||||
| AI 问答 | `POST /api/ai-chat/ask` | ⚠️ 改 system prompt(从几何→AI) |
|
||||
| 问答配额 | `GET /api/ai-chat/quota` | ✅ 零改动 |
|
||||
| 分享获得次数 | `POST /api/ai-chat/share-gain` | ✅ 零改动 |
|
||||
| 创建订单 | `POST /api/payment/create-order` | ✅ 零改动 |
|
||||
| 商品列表 | `GET /api/payment/products` | ✅ 改商品数据 |
|
||||
| 提交反馈 | `POST /api/feedback` | ✅ 零改动 |
|
||||
| 上传作品 | `POST /api/gallery` | ⚠️ 改为 AI 作品展示 |
|
||||
| 作品列表 | `GET /api/gallery` | ✅ 零改动 |
|
||||
|
||||
### ⚠️ 需改造
|
||||
|
||||
| API | 改动内容 | 工作量 |
|
||||
|-----|---------|--------|
|
||||
| `POST /api/ai-chat/ask` | `getSystemPromptByDimension()` 函数 — 从几何 prompt 改为 AI 主题 prompt | 改 1 个函数 |
|
||||
| `GET /api/user/progress` | 原为几何探索进度,改为 AI 学习进度 | 前端改字段名即可 |
|
||||
| `POST /api/user/progress` | 同上 | 前端改字段名即可 |
|
||||
|
||||
### ❌ 移除或降级
|
||||
|
||||
| API | 处理方式 |
|
||||
|-----|---------|
|
||||
| `GET /api/pinyin/*` (4 个路由) | 前端不调用即可,后端保留兼容 |
|
||||
| `GET /api/bgm` | 可作为可选功能保留(背景白噪音) |
|
||||
| `GET /api/share` | 保留,用于分享激励 |
|
||||
|
||||
## 需要新增的 API
|
||||
|
||||
| 新 API | 说明 | 工作量 |
|
||||
|--------|------|--------|
|
||||
| `GET /api/trends` | AI 趋势资讯列表(接入 Hermes cron 的新闻数据) | 新建 model + route,1天 |
|
||||
| `GET /api/tools` | AI 工具导航列表 | 简单 CRUD,小时级 |
|
||||
|
||||
## 改造总结
|
||||
|
||||
**后端改造成本极低,95% 的 API 可以直接用。**
|
||||
- 主要改 1 个函数(AI Chat system prompt)
|
||||
- 新增 1-2 个接口(趋势、工具)
|
||||
- 其余全部零改动
|
||||
|
||||
## 数据库改造
|
||||
- 无需改 schema
|
||||
- Knowledge 表已有 `dim`, `category`, `sections`, `tags`, `isPremium` 等字段
|
||||
- 只需填充 AI 内容数据
|
||||
- AIChatQuota 表可以直接用(原 5 次免费 → 改为 10 次免费)
|
||||
@@ -0,0 +1,91 @@
|
||||
# 宇之然AI维度 - 产品规划文档
|
||||
|
||||
## 产品定位
|
||||
**AI 知识探索社区** — 不是教育平台,不是课程网站,而是一个帮助普通人系统化理解 AI 的互动工具。
|
||||
|
||||
## 目标用户
|
||||
- AI 小白/转行者:想学 AI 但不知从哪开始
|
||||
- 程序员转型:需要 RAG/Agent/微调实操知识
|
||||
- 职场人:想了解 AI 趋势,不被淘汰
|
||||
- AI 爱好者:追踪最新工具和产品动态
|
||||
|
||||
## 核心架构 — 5 个 AI 维度
|
||||
|
||||
| 维度 | 原名 | 改造为 | 内容方向 | 更新频率 |
|
||||
|------|------|--------|---------|---------|
|
||||
| dim1 | 长度探索 | **AI 起源** | 图灵测试→达特茅斯→深蓝→AlphaGo→GPT | 一次填充 |
|
||||
| dim2 | 面积探索 | **AI 发展** | 深度学习→Transformer→MoE→多模态→Agent | 一次填充 |
|
||||
| dim3 | 立体探索 | **AI 当前** | 各模型对比、开源/闭源生态、具身智能 | 季度更新 |
|
||||
| dim4 | 事件探索 | **AI 学习** | 提示词工程→RAG→微调→工具链→入门教程 | 月更新 |
|
||||
| dim5 | 思想探索 | **AI 趋势** | 每日资讯、产品动态、论文快报 | **每日更新** |
|
||||
|
||||
## 功能模块
|
||||
|
||||
### 1. 知识探索(核心)
|
||||
- 5 个维度的结构化 AI 知识
|
||||
- 每篇文章支持:内容阅读 + 问答测验 + 收藏
|
||||
- 来源:管理后台录入 + AI 辅助生成
|
||||
|
||||
### 2. AI 问答(增值)
|
||||
- 用户可就任意 AI 问题提问
|
||||
- 免费用户每日 5 次,Pro 用户无限
|
||||
- 已对接云帆网关 AI 能力
|
||||
|
||||
### 3. 趋势跟踪(差异化)
|
||||
- 每日 AI 新闻速报(来自 Hermes cron)
|
||||
- 热门工具推荐
|
||||
- 产品动态
|
||||
|
||||
### 4. 学习路径(Pro 功能)
|
||||
- 从"AI 小白 → 能上手用 AI 工具"的引导路径
|
||||
- 推荐知识模块和里程碑
|
||||
|
||||
### 5. AI 工具箱(扩展)
|
||||
- 常用 AI 工具导航
|
||||
- 提示词模板库(Pro)
|
||||
|
||||
## 盈利模式
|
||||
| 层 | 内容 | 定价 |
|
||||
|----|------|------|
|
||||
| 免费 | 5 维度知识浏览 + 每日 5 次 AI 问答 | ¥0 |
|
||||
| Pro | 无限 AI 问答 + 趋势跟踪 + 提示词库 | ¥19.9/月 |
|
||||
| VIP | Pro + 学习路径 + 工具教程 | ¥39.9/月 |
|
||||
|
||||
## 技术架构
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ 微信小程序 (uni-app) │
|
||||
│ ┌─────┬──────┬──────┬──────┐ │
|
||||
│ │首页 │维度页│AI问答│趋势 │ │
|
||||
│ └──┬──┴──┬───┴──┬───┴──┬───┘ │
|
||||
│ │知识 │聊天 │资讯 │工具 │
|
||||
└─────┼─────┼──────┼──────┼───────┘
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌─────────────────────────────────┐
|
||||
│ Node.js Express API (:3001) │
|
||||
│ /api/knowledge /api/ai-chat │
|
||||
│ /api/auth /api/gallery │
|
||||
│ /api/user /api/payment │
|
||||
└──────────────┬──────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ MongoDB (wdkj 库) │
|
||||
│ Knowledge / User / AIChatQuota │
|
||||
│ Order / Gallery / Feedback │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 后端改造清单
|
||||
- [ ] 修改 AI Chat 的 system prompt(从几何→AI 主题)
|
||||
- [ ] 新增趋势资讯接口(接入 cron 数据)
|
||||
- [ ] Knowledge 分类增加 category(适配 AI 维度)
|
||||
- [ ] 可选:移除拼音模块路由
|
||||
|
||||
## 前端开发(uni-app 全新)
|
||||
- [ ] 首页:5 维度入口 + 今日 AI 快讯
|
||||
- [ ] 维度页:知识列表 + 问答测验
|
||||
- [ ] AI 问答页:对话界面
|
||||
- [ ] 趋势页:资讯流
|
||||
- [ ] 个人中心:进度/收藏/订阅
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<title>宇之然AI维度 - AI 知识探索社区</title>
|
||||
<meta name="description" content="宇之然AI维度 — 从5个维度系统理解人工智能:AI起源、发展、当前格局、学习工具、趋势跟踪。" />
|
||||
<meta name="keywords" content="宇之然AI维度,AI知识,人工智能学习,AI科普,AI趋势,大模型学习" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "ai-dimension",
|
||||
"version": "1.0.0",
|
||||
"description": "宇之然AI维度 - 微信小程序",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"dev:h5": "uni",
|
||||
"build:h5": "uni build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': set this to true or false
|
||||
core-js: set this to true or false
|
||||
core-js-pure: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
vue-demi: set this to true or false
|
||||
minimumReleaseAgeExclude:
|
||||
- '@dcloudio/uni-mp-weixin@2.0.2-5010520260709001'
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<view class="app-root">
|
||||
<slot />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('[AI维度] App launched')
|
||||
// 初始化主题
|
||||
initTheme()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
console.log('[AI维度] App shown')
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
console.log('[AI维度] App hidden')
|
||||
})
|
||||
|
||||
function initTheme() {
|
||||
// 从本地存储读取主题偏好
|
||||
const theme = uni.getStorageSync('theme') || 'dark'
|
||||
applyTheme(theme)
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'light') {
|
||||
document.documentElement.classList.add('theme-light')
|
||||
} else {
|
||||
document.documentElement.classList.remove('theme-light')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式 */
|
||||
page {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 滚动条隐藏 */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<view
|
||||
class="dim-card-comp"
|
||||
:class="{ 'dim-full': dim.isFull }"
|
||||
:style="{ background: dim.bg }"
|
||||
@tap="$emit('tap', dim)"
|
||||
>
|
||||
<view class="dim-icon" :style="{ background: dim.iconBg, color: dim.color }">
|
||||
<text>{{ dim.icon }}</text>
|
||||
</view>
|
||||
<text class="dim-name" :style="{ color: dim.color }">{{ dim.name }}</text>
|
||||
<text class="dim-desc">{{ dim.desc }}</text>
|
||||
<text class="dim-count">
|
||||
{{ dim.isFull ? '今日已更新 8 条' : `${dim.articleCount} 篇 · ${dim.quizCount} 个问答` }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
dim: { type: Object, required: true }
|
||||
})
|
||||
defineEmits(['tap'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dim-card-comp {
|
||||
border-radius: 18px;
|
||||
padding: 20px 16px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.dim-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dim-icon {
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dim-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
display: block;
|
||||
}
|
||||
.dim-desc {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
.dim-count {
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.15);
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<view
|
||||
class="news-card-comp"
|
||||
@tap="$emit('tap', item)"
|
||||
>
|
||||
<view class="news-tag" :class="item.tagClass">{{ item.tag }}</view>
|
||||
<text class="news-title">{{ item.title }}</text>
|
||||
<view class="news-meta">
|
||||
<text>{{ item.source }}</text>
|
||||
<text>{{ item.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
item: { type: Object, required: true }
|
||||
})
|
||||
defineEmits(['tap'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.news-card-comp {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.news-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.news-tag.hot { background: rgba(239,83,80,0.12); color: #ef5350; }
|
||||
.news-tag.new { background: rgba(0,188,212,0.12); color: #4dd0e1; }
|
||||
.news-tag.pick { background: rgba(124,77,255,0.12); color: #b388ff; }
|
||||
|
||||
.news-title {
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
return { app }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "宇之然AI维度",
|
||||
"appid": "wxdad62baf4ccd09e3",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"description": "AI 知识探索社区 — 从5个维度系统理解人工智能",
|
||||
"uni-app": {
|
||||
"compilerVersion": "3.0.0"
|
||||
},
|
||||
"mp-weixin": {
|
||||
"appid": "wxdad62baf4ccd09e3",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"postcss": true,
|
||||
"minified": true
|
||||
},
|
||||
"usingComponents": true,
|
||||
"permission": {},
|
||||
"requiredPrivateInfos": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"pages": [
|
||||
{"path": "pages/index/index", "style": {"navigationBarTitleText": "AI 维度", "navigationStyle": "custom"}},
|
||||
{"path": "pages/dimension/dimension", "style": {"navigationBarTitleText": "", "navigationStyle": "custom"}},
|
||||
{"path": "pages/chat/chat", "style": {"navigationBarTitleText": "AI 问答", "navigationStyle": "custom"}},
|
||||
{"path": "pages/trend/trend", "style": {"navigationBarTitleText": "AI 趋势", "navigationStyle": "custom"}},
|
||||
{"path": "pages/profile/profile", "style": {"navigationBarTitleText": "我的", "navigationStyle": "custom"}}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "宇之然AI维度",
|
||||
"navigationBarBackgroundColor": "#050508",
|
||||
"backgroundColor": "#050508",
|
||||
"backgroundColorTop": "#050508",
|
||||
"backgroundColorBottom": "#050508",
|
||||
"app-plus": {
|
||||
"background": "#050508"
|
||||
}
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "rgba(255,255,255,0.25)",
|
||||
"selectedColor": "#b388ff",
|
||||
"backgroundColor": "#08080f",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{"pagePath": "pages/index/index", "text": "探索"},
|
||||
{"pagePath": "pages/chat/chat", "text": "问答"},
|
||||
{"pagePath": "pages/trend/trend", "text": "趋势"},
|
||||
{"pagePath": "pages/profile/profile", "text": "我的"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<view class="page-chat">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<view class="chat-header">
|
||||
<view class="chat-avatar">∞</view>
|
||||
<view class="chat-info">
|
||||
<text class="chat-name">AI 助手</text>
|
||||
<text class="chat-status">DeepSeek V4 · 在线</text>
|
||||
</view>
|
||||
<view class="chat-quota">剩余 {{ quota }} 次</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view class="msg-list" scroll-y :scroll-into-view="scrollTo">
|
||||
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="chat-msg" :class="msg.role">
|
||||
<view v-if="msg.role === 'assistant'" class="mini-avatar">∞</view>
|
||||
<view class="msg-content">
|
||||
<view class="bubble">{{ msg.content }}</view>
|
||||
<text class="msg-time">{{ msg.role === 'assistant' ? 'AI 助手' : '我' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading" class="chat-msg ai">
|
||||
<view class="mini-avatar">∞</view>
|
||||
<view class="msg-content">
|
||||
<view class="bubble loading-dots">
|
||||
<text class="dot">.</text><text class="dot">.</text><text class="dot">.</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入区 -->
|
||||
<view class="chat-input-area">
|
||||
<view class="chat-input">
|
||||
<text class="add-btn">➕</text>
|
||||
<input
|
||||
v-model="question"
|
||||
class="input-field"
|
||||
placeholder="输入你的 AI 问题..."
|
||||
:disabled="loading"
|
||||
@confirm="sendMessage"
|
||||
confirm-type="send"
|
||||
/>
|
||||
<view class="send-btn" @tap="sendMessage">
|
||||
<text>➤</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { aiChatApi } from '@/utils/api'
|
||||
|
||||
const question = ref('')
|
||||
const messages = ref([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '你好!我是 AI 维度助手,你可以问我任何关于人工智能的问题,比如:\n\n💡 "Transformer 的核心原理是什么?"\n💡 "RAG 和微调有什么区别?"\n💡 "2026 年最值得关注的 AI 趋势?"'
|
||||
}
|
||||
])
|
||||
const loading = ref(false)
|
||||
const quota = ref(5)
|
||||
const scrollTo = ref('')
|
||||
|
||||
const token = computed(() => uni.getStorageSync('token'))
|
||||
|
||||
async function sendMessage() {
|
||||
const q = question.value.trim()
|
||||
if (!q || loading.value) return
|
||||
|
||||
// 先显示用户消息
|
||||
messages.value.push({ role: 'user', content: q })
|
||||
question.value = ''
|
||||
scrollToLast()
|
||||
|
||||
// 检查登录
|
||||
if (!token.value) {
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: '请先登录后使用 AI 问答功能'
|
||||
})
|
||||
scrollToLast()
|
||||
return
|
||||
}
|
||||
|
||||
// 调 AI 接口
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await aiChatApi.ask(q, 'general', token.value)
|
||||
messages.value.push({ role: 'assistant', content: data.answer })
|
||||
quota.value = data.remainingQuota
|
||||
} catch (e) {
|
||||
// 降级:本地模拟回复
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: `关于「${q}」的问题,这是一个很好的切入点!建议你从对应的 AI 维度开始系统学习。\n\n💡 试试搜索知识库中的相关文章。`
|
||||
})
|
||||
if (quota.value > 0) quota.value--
|
||||
} finally {
|
||||
loading.value = false
|
||||
scrollToLast()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToLast() {
|
||||
setTimeout(() => {
|
||||
scrollTo.value = 'msg-' + (messages.value.length - 1)
|
||||
}, 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-chat {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0 20px;
|
||||
}
|
||||
|
||||
.chat-avatar, .mini-avatar {
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2));
|
||||
border: 1px solid rgba(124,77,255,0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-avatar {
|
||||
width: 32px; height: 32px;
|
||||
font-size: 14px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.chat-info { flex: 1; }
|
||||
.chat-name {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
.chat-status {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chat-quota {
|
||||
background: rgba(124,77,255,0.1);
|
||||
border: 1px solid rgba(124,77,255,0.15);
|
||||
border-radius: 20px;
|
||||
padding: 6px 14px;
|
||||
color: #b388ff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.chat-msg.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
max-width: 82%;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.chat-msg.ai .bubble {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
color: rgba(255,255,255,0.8);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.chat-msg.user .bubble {
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(124,77,255,0.15));
|
||||
border: 1px solid rgba(124,77,255,0.15);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.15);
|
||||
padding-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
.chat-msg.ai .msg-time { padding-left: 0; }
|
||||
.chat-msg.user .msg-time { text-align: right; }
|
||||
|
||||
.loading-dots { display: flex; gap: 2px; }
|
||||
.dot {
|
||||
animation: blink 1.4s infinite;
|
||||
font-size: 24px;
|
||||
line-height: 0.5;
|
||||
color: rgba(255,255,255,0.4);
|
||||
}
|
||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes blink {
|
||||
0%, 20% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 12px 0 30px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.add-btn { color: rgba(255,255,255,0.2); font-size: 18px; }
|
||||
|
||||
.input-field {
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.8);
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 20px rgba(124,77,255,0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<view class="page-dim">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<view class="back-btn" @tap="goBack">
|
||||
<text>← 返回</text>
|
||||
</view>
|
||||
|
||||
<!-- 维度横幅 -->
|
||||
<view class="dim-banner" :style="{ borderColor: currentDim?.border }">
|
||||
<text class="dim-banner-icon">{{ currentDim?.icon }}</text>
|
||||
<text class="dim-banner-title">{{ currentDim?.name }}</text>
|
||||
<text class="dim-banner-desc">{{ bannerDesc }}</text>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
|
||||
</view>
|
||||
<text class="progress-label">探索进度 {{ progress }}% · 已读 {{ readCount }}/{{ totalCount }} 篇</text>
|
||||
</view>
|
||||
|
||||
<!-- 知识列表 -->
|
||||
<view v-for="item in knowledgeList" :key="item._id" class="knowledge-item" @tap="goDetail(item)">
|
||||
<view class="k-icon" :style="getItemStyle(item)">
|
||||
<text>{{ item.icon || '📄' }}</text>
|
||||
</view>
|
||||
<view class="k-info">
|
||||
<text class="k-title">{{ item.title }}</text>
|
||||
<text class="k-desc">{{ item.desc }}</text>
|
||||
<view class="k-meta">
|
||||
<text>📖 {{ item.readTime || 5 }} 分钟</text>
|
||||
<text v-if="item.collected">⭐ 已收藏</text>
|
||||
<text v-else>🔖 {{ item.collectCount || 0 }} 人收藏</text>
|
||||
<text class="k-badge" :class="item.isPremium ? 'pro' : 'free'">
|
||||
{{ item.isPremium ? 'Pro' : '免费' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getDimension } from '@/utils/dimensions'
|
||||
import { knowledgeApi } from '@/utils/api'
|
||||
|
||||
const dimId = ref(1)
|
||||
const currentDim = computed(() => getDimension(dimId.value))
|
||||
const knowledgeList = ref([])
|
||||
const loading = ref(false)
|
||||
const progress = ref(35)
|
||||
const readCount = ref(4)
|
||||
const totalCount = ref(12)
|
||||
|
||||
const bannerDesc = computed(() => {
|
||||
const descs = {
|
||||
1: '从 1950 年图灵测试到 2026 年通用人工智能,追溯 70 年演进之路',
|
||||
2: '从深度学习到 Transformer,从 MoE 到多模态,理解每一次技术跃迁',
|
||||
3: '大模型格局、Agent 生态、具身智能——2026 年 AI 世界全景图',
|
||||
4: '提示词工程、RAG 系统、模型微调、工具链——从入门到上手',
|
||||
5: '每日追踪 AI 前沿动态,不错过每一个重要时刻'
|
||||
}
|
||||
return descs[dimId.value] || '探索人工智能的精彩世界'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
const page = pages[pages.length - 1]
|
||||
if (page.options?.id) {
|
||||
dimId.value = parseInt(page.options.id)
|
||||
}
|
||||
loadKnowledge()
|
||||
})
|
||||
|
||||
async function loadKnowledge() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await knowledgeApi.list({ dim: dimId.value })
|
||||
knowledgeList.value = data.map(item => ({
|
||||
...item,
|
||||
icon: getDimIcon(item.dim),
|
||||
desc: item.content?.substring(0, 40) + '...' || '',
|
||||
readTime: Math.ceil((item.content?.length || 300) / 200),
|
||||
isPremium: item.isPremium,
|
||||
collected: false,
|
||||
collectCount: item.collectionCount
|
||||
}))
|
||||
} catch (e) {
|
||||
// 用示例数据填充
|
||||
knowledgeList.value = getSampleData(dimId.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getItemStyle(item) {
|
||||
const colors = [
|
||||
{ bg: 'rgba(124,77,255,0.1)', color: '#b388ff' },
|
||||
{ bg: 'rgba(0,188,212,0.1)', color: '#4dd0e1' },
|
||||
{ bg: 'rgba(0,200,150,0.1)', color: '#4db6ac' },
|
||||
{ bg: 'rgba(255,193,7,0.1)', color: '#ffd54f' },
|
||||
{ bg: 'rgba(239,83,80,0.1)', color: '#ef5350' }
|
||||
]
|
||||
const c = colors[(item.dim || 1) - 1] || colors[0]
|
||||
return {
|
||||
background: c.bg,
|
||||
color: c.color,
|
||||
borderColor: c.bg
|
||||
}
|
||||
}
|
||||
|
||||
function getDimIcon(dim) {
|
||||
const icons = ['📜', '🏛', '♟', '🧠', '⚡']
|
||||
return icons[(dim || 1) - 1]
|
||||
}
|
||||
|
||||
function getSampleData(dim) {
|
||||
const samples = {
|
||||
1: [
|
||||
{ title: '图灵测试:人工智能的哲学起点', content: '1950 年,艾伦·图灵提出"机器能思考吗?"' },
|
||||
{ title: '达特茅斯会议:AI 的诞生时刻', content: '1956 年,一群科学家正式命名了"人工智能"' },
|
||||
{ title: '深蓝 vs 卡斯帕罗夫', content: '1997 年,IBM 深蓝首次击败国际象棋世界冠军' },
|
||||
],
|
||||
2: [
|
||||
{ title: '深度学习革命', content: '从神经网络到深度学习的演进之路' },
|
||||
{ title: 'Transformer 架构详解', content: '2017 年 Google 提出的革命性架构' },
|
||||
],
|
||||
3: [
|
||||
{ title: '2026 年大模型格局', content: 'GPT、Claude、Gemini、DeepSeek 全面对比' },
|
||||
{ title: 'Agent 智能体生态', content: '从 Chatbot 到自主 Agent 的进化' },
|
||||
],
|
||||
4: [
|
||||
{ title: '提示词工程入门', content: '如何写出高质量的 prompt' },
|
||||
{ title: 'RAG 系统搭建指南', content: '检索增强生成从零到一' },
|
||||
],
|
||||
5: [
|
||||
{ title: 'DeepSeek 自研推理芯片', content: '降低对英伟达依赖的重要一步' },
|
||||
{ title: 'WAIC 2026 即将开幕', content: '7 月 17 日上海,1100+ 企业参展' },
|
||||
]
|
||||
}
|
||||
const data = samples[dim] || samples[1]
|
||||
return data.map((item, i) => ({
|
||||
...item,
|
||||
_id: `sample-${dim}-${i}`,
|
||||
dim,
|
||||
icon: getDimIcon(dim),
|
||||
readTime: 5 + i * 2,
|
||||
isPremium: i >= 3,
|
||||
collected: i === 0,
|
||||
collectionCount: 10 - i * 2
|
||||
}))
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goDetail(item) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/dimension/detail?id=${item._id}`
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-dim {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 40px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.back-btn {
|
||||
color: rgba(255,255,255,0.4);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 0 14px;
|
||||
}
|
||||
|
||||
.dim-banner {
|
||||
border-radius: 18px;
|
||||
padding: 24px 20px;
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.1), rgba(0,0,0,0.3));
|
||||
border: 1px solid rgba(124,77,255,0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dim-banner-icon { font-size: 28px; display: block; margin-bottom: 8px; }
|
||||
.dim-banner-title { font-size: 22px; color: #fff; display: block; margin-bottom: 6px; }
|
||||
.dim-banner-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 2px;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #b388ff, #7c4dff);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.progress-label {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.2);
|
||||
margin-top: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.knowledge-item {
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.k-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.k-info { flex: 1; }
|
||||
.k-title {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.k-desc {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
.k-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.15);
|
||||
}
|
||||
.k-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.k-badge.free { background: rgba(76,175,80,0.12); color: #66bb6a; }
|
||||
.k-badge.pro { background: rgba(255,152,0,0.12); color: #ffa726; }
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: rgba(255,255,255,0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<view class="page-home">
|
||||
<!-- 状态栏占位 -->
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<view class="header">
|
||||
<view class="brand">
|
||||
<view class="brand-icon">✦</view>
|
||||
<view class="brand-text">
|
||||
<text class="title">AI 维度</text>
|
||||
<text class="subtitle">宇之然 · 探索</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<view class="icon-btn" @tap="onNotify">
|
||||
<text>🔔</text>
|
||||
</view>
|
||||
<view class="icon-btn" @tap="goProfile">
|
||||
<text>👤</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 问候语 -->
|
||||
<view class="greeting">
|
||||
<text class="greeting-title">探索 </text>
|
||||
<text class="greeting-highlight">人工智能</text>
|
||||
<text class="greeting-desc">从 5 个维度,系统理解 AI 的过去、现在与未来</text>
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar" @tap="onSearch">
|
||||
<text class="search-icon">🔍</text>
|
||||
<text class="search-placeholder">搜索 AI 知识...</text>
|
||||
</view>
|
||||
|
||||
<!-- 五维度网格 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">✦ 五个维度</text>
|
||||
</view>
|
||||
<view class="dim-grid">
|
||||
<view
|
||||
v-for="dim in dimensions"
|
||||
:key="dim.id"
|
||||
class="dim-card"
|
||||
:class="{ 'dim-full': dim.isFull }"
|
||||
:style="{ background: dim.bg }"
|
||||
@tap="goDimension(dim)"
|
||||
>
|
||||
<view class="dim-icon" :style="{ background: dim.iconBg, color: dim.color }">
|
||||
<text>{{ dim.icon }}</text>
|
||||
</view>
|
||||
<text class="dim-name" :style="{ color: dim.color }">{{ dim.name }}</text>
|
||||
<text class="dim-desc">{{ dim.desc }}</text>
|
||||
<text class="dim-count">
|
||||
{{ dim.isFull ? '今日已更新 8 条' : `${dim.articleCount} 篇 · ${dim.quizCount} 个问答` }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日快讯 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">📡 今日快讯</text>
|
||||
<text class="section-more" @tap="goTrend">查看全部 →</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(item, idx) in newsList"
|
||||
:key="idx"
|
||||
class="news-card"
|
||||
@tap="goNewsDetail(item)"
|
||||
>
|
||||
<view class="news-tag" :class="item.tagClass">{{ item.tag }}</view>
|
||||
<text class="news-title">{{ item.title }}</text>
|
||||
<view class="news-meta">
|
||||
<text>{{ item.source }}</text>
|
||||
<text>{{ item.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-spacer"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { DIMENSIONS } from '@/utils/dimensions'
|
||||
import { trendApi } from '@/utils/api'
|
||||
|
||||
const dimensions = ref(DIMENSIONS)
|
||||
|
||||
const newsList = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
fetchNews()
|
||||
})
|
||||
|
||||
async function fetchNews() {
|
||||
try {
|
||||
const data = await trendApi.list({ limit: 5 })
|
||||
newsList.value = data.list.map(item => ({
|
||||
tag: item.hot ? '🔥 热门' : item.picked ? '📌 精选' : '🚀 最新',
|
||||
tagClass: item.hot ? 'hot' : item.picked ? 'pick' : 'new',
|
||||
title: item.title,
|
||||
source: item.source,
|
||||
time: formatTime(item.newsDate),
|
||||
_id: item._id
|
||||
}))
|
||||
} catch (e) {
|
||||
// 降级:本地数据
|
||||
newsList.value = [
|
||||
{ tag: '🔥 热门', tagClass: 'hot', title: 'DeepSeek 被曝自研推理芯片', source: '量子位', time: '2 小时前' },
|
||||
{ tag: '🚀 最新', tagClass: 'new', title: '蚂蚁灵波开源 LingBot-World 2.0', source: '量子位', time: '4 小时前' },
|
||||
{ tag: '📌 精选', tagClass: 'pick', title: '阿里获 ACL 2026 最佳资源论文奖', source: '机器之心', time: '6 小时前' }
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
if (hours < 1) return '刚刚'
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
return `${Math.floor(hours / 24)} 天前`
|
||||
}
|
||||
|
||||
function goDimension(dim) {
|
||||
uni.navigateTo({ url: `/pages/dimension/dimension?id=${dim.id}&name=${dim.name}` })
|
||||
}
|
||||
|
||||
function goTrend() {
|
||||
uni.switchTab({ url: '/pages/trend/trend' })
|
||||
}
|
||||
|
||||
function goProfile() {
|
||||
uni.switchTab({ url: '/pages/profile/profile' })
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
uni.showToast({ title: '搜索功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function onNotify() {
|
||||
uni.showToast({ title: '暂无新消息', icon: 'none' })
|
||||
}
|
||||
|
||||
function goNewsDetail(item) {
|
||||
uni.switchTab({ url: '/pages/trend/trend' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-home {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0 20px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2));
|
||||
border: 1px solid rgba(124,77,255,0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 0 20px rgba(124,77,255,0.1);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand-text .title {
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.brand-text .subtitle {
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.icon-btn {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.greeting-title {
|
||||
font-size: 26px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.greeting-highlight {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #b388ff, #7c4dff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.greeting-desc {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.search-icon { color: rgba(255,255,255,0.2); }
|
||||
.search-placeholder { color: rgba(255,255,255,0.2); font-size: 14px; }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 17px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.section-more {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.dim-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.dim-card {
|
||||
border-radius: 18px;
|
||||
padding: 20px 16px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.dim-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dim-icon {
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dim-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
display: block;
|
||||
}
|
||||
.dim-desc {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
.dim-count {
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.15);
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.news-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.news-tag.hot {
|
||||
background: rgba(239,83,80,0.12);
|
||||
color: #ef5350;
|
||||
}
|
||||
.news-tag.new {
|
||||
background: rgba(0,188,212,0.12);
|
||||
color: #4dd0e1;
|
||||
}
|
||||
.news-tag.pick {
|
||||
background: rgba(124,77,255,0.12);
|
||||
color: #b388ff;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.bottom-spacer {
|
||||
height: 40px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<view class="page-profile">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<view class="profile-card">
|
||||
<view class="profile-avatar">
|
||||
<text>{{ userInfo?.avatarUrl ? '🖼' : '🧑' }}</text>
|
||||
</view>
|
||||
<text class="profile-name">{{ userInfo?.nickName || '星辰旅者' }}</text>
|
||||
<text class="profile-level">Lv.5 · 维度学徒 · 已探索 12 篇文章</text>
|
||||
</view>
|
||||
|
||||
<!-- 订阅卡片 -->
|
||||
<view class="plan-card" @tap="upgradePlan">
|
||||
<view class="plan-info">
|
||||
<text class="plan-title">✦ 免费版</text>
|
||||
<text class="plan-desc">{{ isLoggedIn ? '每日 5 次 AI 问答 · 解锁全部知识' : '登录后开始你的 AI 探索之旅' }}</text>
|
||||
</view>
|
||||
<view class="plan-btn">
|
||||
<text>{{ isLoggedIn ? '升级 Pro' : '登录/注册' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 菜单 -->
|
||||
<view class="menu-item" @tap="goFavorites">
|
||||
<view class="m-icon" :style="{ background: 'rgba(124,77,255,0.1)', color: '#b388ff' }">📚</view>
|
||||
<text class="m-label">我的收藏</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @tap="goProgress">
|
||||
<view class="m-icon" :style="{ background: 'rgba(0,188,212,0.1)', color: '#4dd0e1' }">📊</view>
|
||||
<text class="m-label">学习进度</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @tap="goAchievements">
|
||||
<view class="m-icon" :style="{ background: 'rgba(0,200,150,0.1)', color: '#4db6ac' }">🏆</view>
|
||||
<text class="m-label">成就</text>
|
||||
<text class="m-badge">3 项未解锁</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @tap="checkin">
|
||||
<view class="m-icon" :style="{ background: 'rgba(255,193,7,0.1)', color: '#ffd54f' }">📅</view>
|
||||
<text class="m-label">每日签到</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @tap="goSettings">
|
||||
<view class="m-icon" :style="{ background: 'rgba(239,83,80,0.1)', color: '#ef5350' }">⚙️</view>
|
||||
<text class="m-label">设置</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
|
||||
<!-- 登录/退出 -->
|
||||
<view v-if="isLoggedIn" class="logout-btn" @tap="handleLogout">
|
||||
<text>退出登录</text>
|
||||
</view>
|
||||
<view v-else class="logout-btn" @tap="handleLogin">
|
||||
<text>微信一键登录</text>
|
||||
</view>
|
||||
|
||||
<view class="version">宇之然AI维度 v1.0.0</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const userInfo = ref(null)
|
||||
const isLoggedIn = computed(() => !!uni.getStorageSync('token'))
|
||||
|
||||
function upgradePlan() {
|
||||
if (!isLoggedIn.value) {
|
||||
handleLogin()
|
||||
return
|
||||
}
|
||||
uni.showToast({ title: '订阅功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function handleLogin() {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (loginRes) => {
|
||||
const code = loginRes.code
|
||||
uni.getUserProfile({
|
||||
desc: '用于完善用户资料',
|
||||
success: (profileRes) => {
|
||||
const userInfo = profileRes.userInfo
|
||||
// 调用登录 API
|
||||
console.log('登录 code:', code, 'userInfo:', userInfo)
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: '微信登录失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
uni.removeStorageSync('token')
|
||||
userInfo.value = null
|
||||
uni.showToast({ title: '已退出', icon: 'success' })
|
||||
}
|
||||
|
||||
function goFavorites() { uni.showToast({ title: '收藏功能开发中', icon: 'none' }) }
|
||||
function goProgress() { uni.showToast({ title: '学习进度开发中', icon: 'none' }) }
|
||||
function goAchievements() { uni.showToast({ title: '成就系统开发中', icon: 'none' }) }
|
||||
function checkin() { uni.showToast({ title: '签到功能开发中', icon: 'none' }) }
|
||||
function goSettings() { uni.showToast({ title: '设置功能开发中', icon: 'none' }) }
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-profile {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 40px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.profile-card {
|
||||
text-align: center;
|
||||
padding: 20px 0 24px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 72px; height: 72px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.15));
|
||||
border: 2px solid rgba(124,77,255,0.2);
|
||||
margin: 0 auto 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
box-shadow: 0 0 40px rgba(124,77,255,0.1);
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
.profile-level {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.08), rgba(0,0,0,0.2));
|
||||
border: 1px solid rgba(124,77,255,0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-info { flex: 1; }
|
||||
.plan-title {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
.plan-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plan-btn {
|
||||
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
||||
padding: 10px 22px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 0 20px rgba(124,77,255,0.2);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.m-icon {
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.m-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.m-arrow {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.m-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(239,83,80,0.12);
|
||||
color: #ef5350;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
text-align: center;
|
||||
padding: 18px 0;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.version {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.1);
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<view class="page-trend">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<view class="section-header" style="margin-bottom: 4px;">
|
||||
<text class="section-title">🔥 AI 趋势</text>
|
||||
<text class="section-more">订阅</text>
|
||||
</view>
|
||||
<text class="trend-desc">每日追踪 AI 前沿动态,不错过每一个重要时刻</text>
|
||||
|
||||
<!-- 分类筛选 -->
|
||||
<scroll-view class="filter-bar" scroll-x>
|
||||
<view
|
||||
v-for="(tag, idx) in tags"
|
||||
:key="idx"
|
||||
class="filter-tag"
|
||||
:class="{ active: currentTag === idx }"
|
||||
@tap="currentTag = idx"
|
||||
>
|
||||
<text>{{ tag }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 趋势卡片 -->
|
||||
<view
|
||||
v-for="(item, idx) in filteredTrends"
|
||||
:key="idx"
|
||||
class="trend-card"
|
||||
:style="{ borderColor: item.borderColor }"
|
||||
>
|
||||
<view class="trend-marker" :style="{ background: item.markerColor }"></view>
|
||||
<view class="trend-content">
|
||||
<text class="trend-tag" :style="{ background: item.tagBg, color: item.tagColor }">{{ item.tag }}</text>
|
||||
<text class="trend-title">{{ item.title }}</text>
|
||||
<text class="trend-source">{{ item.source }} · {{ item.date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-spacer"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { trendApi } from '@/utils/api'
|
||||
|
||||
const tags = ['全部', '🚀 产品', '📄 论文', '🔧 工具', '🏢 公司']
|
||||
const currentTag = ref(0)
|
||||
|
||||
const trends = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
fetchTrends()
|
||||
})
|
||||
|
||||
async function fetchTrends() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await trendApi.list({ limit: 50 })
|
||||
trends.value = data.list.map(item => ({
|
||||
tag: item.hot ? '🔥 热门' : item.picked ? '📌 精选' : getCategoryTag(item.category),
|
||||
tagBg: getTagBg(item.category),
|
||||
tagColor: getTagColor(item.category),
|
||||
borderColor: `${getTagBg(item.category).replace('0.12', '0.08')}`,
|
||||
markerColor: getMarkerGradient(item.category),
|
||||
title: item.title,
|
||||
source: item.source,
|
||||
date: formatDate(item.newsDate),
|
||||
category: item.category,
|
||||
_id: item._id
|
||||
}))
|
||||
} catch (e) {
|
||||
// Fallback data
|
||||
trends.value = getFallbackData()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryTag(cat) {
|
||||
const map = { '产品': '🚀 产品', '论文': '📄 论文', '工具': '🔧 工具', '公司': '🏢 公司', '政策': '🏛 政策' }
|
||||
return map[cat] || '📌 其他'
|
||||
}
|
||||
|
||||
const tagStyleMap = {
|
||||
'产品': { bg: 'rgba(0,188,212,0.12)', color: '#4dd0e1', gradient: 'linear-gradient(180deg, #4dd0e1, #80deea)' },
|
||||
'论文': { bg: 'rgba(0,200,150,0.12)', color: '#4db6ac', gradient: 'linear-gradient(180deg, #4db6ac, #80cbc4)' },
|
||||
'工具': { bg: 'rgba(255,193,7,0.12)', color: '#ffd54f', gradient: 'linear-gradient(180deg, #ffd54f, #ffe082)' },
|
||||
'公司': { bg: 'rgba(239,83,80,0.12)', color: '#ef5350', gradient: 'linear-gradient(180deg, #ef5350, #ff8a80)' },
|
||||
'其他': { bg: 'rgba(124,77,255,0.12)', color: '#b388ff', gradient: 'linear-gradient(180deg, #b388ff, #d1c4e9)' }
|
||||
}
|
||||
|
||||
function getTagBg(cat) { return tagStyleMap[cat]?.bg || tagStyleMap['其他'].bg }
|
||||
function getTagColor(cat) { return tagStyleMap[cat]?.color || tagStyleMap['其他'].color }
|
||||
function getMarkerGradient(cat) { return tagStyleMap[cat]?.gradient || tagStyleMap['其他'].gradient }
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function getFallbackData() {
|
||||
return [
|
||||
{ tag: '🔥 热门', tagBg: 'rgba(239,83,80,0.12)', tagColor: '#ef5350', borderColor: 'rgba(239,83,80,0.08)', markerColor: 'linear-gradient(180deg, #ef5350, #ff8a80)', title: 'DeepSeek 秘密造芯', source: '路透社', date: '2026-07-10', category: '公司' },
|
||||
{ tag: '🚀 产品', tagBg: 'rgba(0,188,212,0.12)', tagColor: '#4dd0e1', borderColor: 'rgba(0,188,212,0.08)', markerColor: 'linear-gradient(180deg, #4dd0e1, #80deea)', title: '蚂蚁灵波开源 LingBot-World 2.0', source: '量子位', date: '2026-07-09', category: '产品' },
|
||||
{ tag: '📄 论文', tagBg: 'rgba(0,200,150,0.12)', tagColor: '#4db6ac', borderColor: 'rgba(0,200,150,0.08)', markerColor: 'linear-gradient(180deg, #4db6ac, #80cbc4)', title: '阿里获 ACL 2026 最佳资源论文奖', source: '机器之心', date: '2026-07-09', category: '论文' },
|
||||
]
|
||||
}
|
||||
|
||||
const filteredTrends = computed(() => {
|
||||
if (currentTag.value === 0) return trends.value
|
||||
const category = tags[currentTag.value].replace(/^[^\s]+\s/, '')
|
||||
return trends.value.filter(t => t.category === category)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-trend {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 40px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 17px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.section-more {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.trend-desc {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.filter-tag.active {
|
||||
background: rgba(124,77,255,0.2);
|
||||
color: #b388ff;
|
||||
border-color: rgba(124,77,255,0.2);
|
||||
}
|
||||
.filter-tag:not(.active) {
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.trend-card {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.trend-marker {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.trend-content {
|
||||
padding-left: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trend-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.trend-title {
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trend-source {
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bottom-spacer { height: 40px; }
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="6" y="6" width="16" height="16" rx="3" fill="#b388ff"/>
|
||||
<rect x="26" y="6" width="16" height="16" rx="3" fill="#b388ff"/>
|
||||
<rect x="6" y="26" width="16" height="16" rx="3" fill="#b388ff"/>
|
||||
<rect x="26" y="26" width="16" height="16" rx="3" fill="#b388ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 351 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="6" y="6" width="16" height="16" rx="3" fill="#444"/>
|
||||
<rect x="26" y="6" width="16" height="16" rx="3" fill="#444"/>
|
||||
<rect x="6" y="26" width="16" height="16" rx="3" fill="#444"/>
|
||||
<rect x="26" y="26" width="16" height="16" rx="3" fill="#444"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 339 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M24 4L28 16L40 18L30 26L34 40L24 32L14 40L18 26L8 18L20 16L24 4Z" fill="#b388ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 173 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M24 4L28 16L40 18L30 26L34 40L24 32L14 40L18 26L8 18L20 16L24 4Z" fill="#444"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 170 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="16" r="8" fill="#b388ff"/>
|
||||
<path d="M10 44C10 32 16 26 24 26C32 26 38 32 38 44H10Z" fill="#b388ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 204 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="16" r="8" fill="#444"/>
|
||||
<path d="M10 44C10 32 16 26 24 26C32 26 38 32 38 44H10Z" fill="#444"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 198 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M8 36L24 8L40 36H8Z" fill="#b388ff"/>
|
||||
<rect x="22" y="28" width="4" height="8" rx="1" fill="#7c4dff"/>
|
||||
<circle cx="24" cy="22" r="2" fill="#7c4dff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M8 36L24 8L40 36H8Z" fill="#444"/>
|
||||
<rect x="22" y="28" width="4" height="8" rx="1" fill="#222"/>
|
||||
<circle cx="24" cy="22" r="2" fill="#222"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 235 B |