🎉 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
This commit is contained in:
Yuzhiran Dev
2026-07-10 13:26:04 +08:00
commit 24cd9cef53
107 changed files with 20665 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
pnpm-lock.yaml
.env
*.log
+54
View File
@@ -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维度
- 小程序 appidwxdad62baf4ccd09e3
- 后端主域名:wdkj.yuzhiran.com.cn → :3001
- 管理后台:wdkjadmin.yzrcloud.cn
- H5 地址:www.wdkj.yuzhiran.com.cn/h5/
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
logs/
*.log
.DS_Store
+263
View File
@@ -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
+25
View File
@@ -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-----
+28
View File
@@ -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-----
+9
View File
@@ -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-----
+13
View File
@@ -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
}
+5763
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -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"
}
}
+19
View File
@@ -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
+37
View File
@@ -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
+36
View File
@@ -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()
+171
View File
@@ -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()
+218
View File
@@ -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()
+182
View File
@@ -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 }
+241
View File
@@ -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();
+42
View File
@@ -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;
}
}
+97
View File
@@ -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
+167
View File
@@ -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
}
+113
View File
@@ -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)
+117
View File
@@ -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)
+130
View File
@@ -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)
+54
View File
@@ -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)
+66
View File
@@ -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)
+191
View File
@@ -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)
+106
View File
@@ -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)
+125
View File
@@ -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)
+79
View File
@@ -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)
+142
View File
@@ -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)
+120
View File
@@ -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)
+25
View File
@@ -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)
+109
View File
@@ -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)
+33
View File
@@ -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);
+305
View File
@@ -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)
+38
View File
@@ -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
}
+110
View File
@@ -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);
+120
View File
@@ -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);
+142
View File
@@ -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);
+189
View File
@@ -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);
+9
View File
@@ -0,0 +1,9 @@
/**
* 拼音探索模块模型导出
*/
module.exports = {
PinyinContent: require('./PinyinContent'),
PinyinProgress: require('./PinyinProgress'),
PinyinAchievement: require('./PinyinAchievement'),
PinyinGameRecord: require('./PinyinGameRecord')
};
File diff suppressed because it is too large Load Diff
+240
View File
@@ -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
+351
View File
@@ -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
+492
View File
@@ -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 中传递 openidH5 用这个接口换取 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
+201
View File
@@ -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
+228
View File
@@ -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
+209
View File
@@ -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
+57
View File
@@ -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)
}
+186
View File
@@ -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
+266
View File
@@ -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
+15
View File
@@ -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;
+16
View File
@@ -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;
+24
View File
@@ -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;
+14
View File
@@ -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
};
+9
View File
@@ -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;
+67
View File
@@ -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
+83
View File
@@ -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;
+424
View File
@@ -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
+176
View File
@@ -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: 'BERT2018)展示了双向编码器的强大,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 走 B2B2CAnthropic 专注企业市场。',
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: 'LangChainAI 应用开发框架',
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);
});
+395
View File
@@ -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); });
+96
View File
@@ -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
+95
View File
@@ -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
+235
View File
@@ -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
+211
View File
@@ -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)
})
})
})
+205
View File
@@ -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)
})
})
})
+27
View File
@@ -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()
}
}
+166
View File
@@ -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)
})
})
})
+856
View File
@@ -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-2026Transformer 如何改变一切</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>
+58
View File
@@ -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 + route1天 |
| `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 次免费)
+91
View File
@@ -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 问答页:对话界面
- [ ] 趋势页:资讯流
- [ ] 个人中心:进度/收藏/订阅
+15
View File
@@ -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>
+30
View File
@@ -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'
+57
View File
@@ -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>
+10
View File
@@ -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 }
}
+22
View File
@@ -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": []
}
}
+32
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More