feat: update admin panel, API modules, and add migrations

- Update food, outfits, props, home-decor pages and components
- Add permissions page and sidebar updates
- Update API client and all API modules (auth, food, dances, etc.)
- Add card model migrations for optional fields
- Update Django views, serializers, and authentication
- Add affinity level migrations and user app updates
- Add project documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
pmc 2026-03-20 13:06:50 +08:00
parent 0c610c1e49
commit bd95ba470c
61 changed files with 5983 additions and 3780 deletions

122
.claude/settings.json Normal file
View File

@ -0,0 +1,122 @@
{
"permissions": {
"allow": [
"Bash(npm install:*)",
"Bash(where node:*)",
"Bash(where npm:*)",
"Bash(where pnpm:*)",
"Bash(where yarn:*)",
"Bash(cmd.exe /c \"where node 2>nul & where npm 2>nul & where pnpm 2>nul & where yarn 2>nul\")",
"Bash(cmd.exe /c \"node --version && npm --version\")",
"Bash(export PATH=\"/c/Program Files/nodejs:$PATH\")",
"Bash(npm --version)",
"Bash(npm run:*)",
"Bash(npx next:*)",
"Bash(pip install:*)",
"Bash(python manage.py migrate)",
"Bash(python manage.py runserver 0.0.0.0:8000)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8000/api/)",
"Bash(find c:/Users/admin/Desktop/Lila-Server/qy-lty-admin -type f -name *.ts -o -name *.tsx -o -name *.jsx -o -name *.js)",
"Bash(curl -s -o /dev/null -w \"HTTP %{http_code}\" http://localhost:8000/swagger/)",
"Bash(grep -r \"WebSocket\\\\|websocket\\\\|ws://\" /c/Users/admin/Desktop/Lila-Server/qy-lty-admin --include=*.ts --include=*.tsx --include=*.js)",
"Bash(grep -r \"WebSocket\\\\|websocket\" /c/Users/admin/Desktop/Lila-Server/qy_lty --include=*.py)",
"Bash(xargs grep:*)",
"Bash(grep -r \"apiClient\\\\|API_BASE_URL\\\\|http://localhost:8000\" /c/Users/admin/Desktop/Lila-Server/qy-lty-admin/app --include=*.ts --include=*.tsx)",
"Bash(find /c/Users/admin/Desktop/Lila-Server/qy_lty -type d -name *affinity* -o -name *好感度*)",
"Bash(xargs cat:*)",
"Bash(grep -E \"\\\\.py$\")",
"Bash(python manage.py makemigrations userapp)",
"Bash(tasklist)",
"Bash(wmic process:*)",
"Bash(taskkill /PID 21260 /PID 54320 /PID 66096 /PID 68736 /F)",
"Bash(powershell -Command \"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match ''runserver'' } | Select-Object ProcessId, CommandLine\")",
"Bash(powershell -Command \"Stop-Process -Id 21260, 54320, 66096, 68736 -Force -ErrorAction SilentlyContinue; Write-Output ''Done''\")",
"Read(//tmp/**)",
"Bash(powershell -Command \"Test-NetConnection -ComputerName localhost -Port 8000 -InformationLevel Quiet\")",
"Bash(curl -s -X POST http://localhost:8000/api/v1/admin/login/ -H \"Content-Type: application/json\" -d '{\"\"email\"\":\"\"111111@qq.com\"\",\"\"password\"\":\"\"111111\"\"}')",
"Bash(curl -s -w \"\\\\nHTTP_CODE:%{http_code}\" \"http://localhost:8000/api/card/category/song/?page_size=5&page=1\")",
"Bash(curl -s -w \"\\\\nHTTP_CODE:%{http_code}\" -H \"Authorization: Bearer fake_token_12345\" \"http://localhost:8000/api/card/category/song/?page_size=5&page=1\")",
"Skill(update-config)",
"Skill(update-config:*)",
"Bash(python manage.py shell -c \":*)",
"Bash(grep -E \"\\\\.\\(tsx|ts|jsx|js\\)$\")",
"Read(//c/Users/admin/Desktop/Lila-Server/**)",
"Bash(npx tsc:*)",
"Bash(node ./node_modules/.bin/tsc --noEmit)",
"Read(//c/Program Files/nodejs/**)",
"Read(//c/Users/admin/AppData/Roaming/**)",
"Bash(python qy_lty/manage.py makemigrations card --name prop_attributes_optional_fields)",
"Bash(python qy_lty/manage.py migrate card)",
"Bash(ls c:UsersadminDesktopLila-Serverqy_lty/.env*)",
"Bash(ls \"c:/Users/admin/Desktop/Lila-Server/qy_lty/\"*.env*)",
"Bash(python -c \"from common.oss import OSSUploader; u = OSSUploader\\(\\); print\\(''OSSUploader init OK''\\)\")",
"Bash(DJANGO_SETTINGS_MODULE=qy_lty.settings python -c \":*)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8000/api/common/upload/)",
"Read(//dev/**)",
"Bash(mkdir -p /tmp)",
"Bash(\"c:/Users/admin/Desktop/test.png\")",
"Bash(curl -s -w \"\\\\n%{http_code}\" -X POST http://localhost:8000/api/common/upload/ -F \"file=@c:/Users/admin/Desktop/test.png;type=image/png\" -H \"Authorization: Bearer 3ade1cea-4ee4-4ea0-b5ae-5295a183eca8\")",
"Bash(node_modules/.bin/tsc --noEmit --pretty)",
"Bash(python manage.py migrate card 0011_cardtemplate_image_to_urlfield)",
"Bash(python manage.py showmigrations card)",
"Bash(curl -s http://localhost:8000/api/)",
"Bash(python manage.py check)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:8000/)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:38000/api/)",
"Bash(curl -s \"http://127.0.0.1:8000/api/card/category/prop/?page=1&page_size=10\")",
"Bash(node_modules/.bin/next build:*)",
"Bash(\"/c/Program Files/nodejs/node.exe\" node_modules/.bin/next build --no-lint)",
"Bash(\"/c/Program Files/nodejs/node.exe\" node_modules/next/dist/bin/next build --no-lint)",
"Bash(curl -s http://localhost:3000)",
"Bash(taskkill //F //IM node.exe)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000)",
"Bash(python manage.py makemigrations card --name clothing_attributes_optional_fields)",
"Bash(python manage.py migrate card)",
"Bash(findstr /i 'python daphne')",
"Bash(powershell \"Get-CimInstance Win32_Process -Filter \"\"name=''python.exe''\"\" | Select-Object ProcessId,CommandLine | Format-List\")",
"Bash(taskkill //F //PID 22852 //T)",
"Bash(taskkill //F //PID 85184 //T)",
"Read(//c/Users/admin/AppData/Local/Temp/claude/c--Users-admin-Desktop-Lila-Server/a10d9a40-b774-4f1d-8741-be0671dbb102/tasks/**)",
"Bash(findstr /i python)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8000/api/card/templates/)",
"Bash(python -c \":*)",
"Bash(curl -s -X POST http://localhost:8000/api/card/templates/ -H \"Content-Type: application/json\" -d '{\"\"name\"\":\"\"test\"\",\"\"category\"\":\"\"clothing\"\",\"\"description\"\":\"\"test\"\",\"\"rarity\"\":\"\"common\"\",\"\"clothing_attributes\"\":{\"\"style\"\":\"\"常规服装\"\"}}')",
"Bash(curl -s -X POST http://localhost:8000/api/card/templates/ -H \"Content-Type: application/json\" -H \"Authorization: Bearer 51ae5483-86d0-4b39-86ee-18b50938bf6d\" -d '{\"\"name\"\":\"\"test111\"\",\"\"category\"\":\"\"clothing\"\",\"\"description\"\":\"\"111\"\",\"\"rarity\"\":\"\"common\"\",\"\"clothing_attributes\"\":{\"\"style\"\":\"\"常规服装\"\"}}')",
"Bash(curl -s -X POST http://localhost:8000/api/card/templates/ -H \"Content-Type: application/json; charset=utf-8\" -H \"Authorization: Bearer 51ae5483-86d0-4b39-86ee-18b50938bf6d\" --data-raw \"{\"\"name\"\":\"\"test111\"\",\"\"category\"\":\"\"clothing\"\",\"\"description\"\":\"\"111\"\",\"\"rarity\"\":\"\"common\"\",\"\"clothing_attributes\"\":{\"\"style\"\":\"\"regular\"\"}}\")",
"Bash(powershell \"Get-CimInstance Win32_Process -Filter \"\"name=''''python.exe''''\"\" | Select-Object ProcessId,CommandLine\")",
"Bash(taskkill //F //PID 93368 //T)",
"Bash(taskkill //F //PID 34292 //T)",
"Bash(curl -s -X POST http://localhost:8000/api/card/templates/ -H \"Content-Type: application/json; charset=utf-8\" -H \"Authorization: Bearer c0e1bca3-3bd5-451f-9411-289cc51dac08\" --data-raw '{\"\"name\"\":\"\"test_outfit\"\",\"\"category\"\":\"\"clothing\"\",\"\"description\"\":\"\"test desc\"\",\"\"rarity\"\":\"\"common\"\",\"\"clothing_attributes\"\":{\"\"style\"\":\"\"regular\"\"}}')",
"Bash(find /c/Users/admin/Desktop/Lila-Server/qy_lty -name *prop* -type f)",
"Bash(grep -E \"\\\\.\\(py|json\\)$\")",
"Bash(find /c/Users/admin/Desktop/Lila-Server/qy_lty -name *home* -type f)",
"Bash(ls /c/Users/admin/Desktop/Lila-Server/qy-lty-admin/app/home-decor/[id]/)",
"Bash(python manage.py makemigrations card --name decoration_furniture_optional_fields)",
"Bash(powershell \"Get-CimInstance Win32_Process -Filter \"\"name=''python.exe'' AND CommandLine LIKE ''%runserver%''\"\" | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }; Write-Host ''Killed''\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8000/api/card/category/decoration/)",
"Bash(TOKEN=\"9f70f7da-7bce-4c98-b7c3-3a9414ddab44\")",
"Bash(curl -s http://localhost:8000/api/user/ -H \"Authorization: Token $TOKEN\")",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(''''status:'''', d.get\\(''''code'''',''''?''''\\), ''''count:'''', d.get\\(''''data'''',{}\\).get\\(''''count'''',''''?''''\\)\\)\")",
"Bash(curl -s http://localhost:8000/api/card/category/dance/ -H 'Authorization: Token $TOKEN')",
"Bash(curl -s http://localhost:8000/api/card/category/clothing/ -H 'Authorization: Token $TOKEN')",
"Bash(curl -s http://localhost:8000/api/card/category/prop/ -H 'Authorization: Token $TOKEN')",
"Bash(curl -s http://localhost:8000/api/card/category/decoration/ -H 'Authorization: Token $TOKEN')",
"Bash(curl -s \"http://localhost:8000/api/food/foods/\" -H \"Authorization: Token $TOKEN\")",
"Bash(curl -s http://localhost:8000/api/achievement/achievements/ -H 'Authorization: Token $TOKEN')",
"Bash(curl -s http://localhost:8000/api/user/affinity-rules/ -H 'Authorization: Token $TOKEN')",
"Bash(curl -s http://localhost:8000/api/user/affinity-levels/ -H 'Authorization: Token $TOKEN')",
"Bash(curl -s http://localhost:8000/api/ai/bots/ -H 'Authorization: Token $TOKEN')",
"Bash(curl -s http://localhost:8000/api/user/groups/ -H 'Authorization: Token $TOKEN')",
"Bash(curl -s \"http://localhost:8000/api/card/category/song/\" -H \"Authorization: Token $TOKEN\")",
"Bash(curl -s http://localhost:8000/api/device/device-types/ -H 'Authorization: Token $TOKEN')",
"Bash(curl -s http://localhost:8000/api/device/devices/ -H 'Authorization: Token $TOKEN')",
"Bash(curl -s -X POST http://localhost:8000/api/user/auth/register/ -H 'Content-Type: application/json' -d {})",
"Bash(curl -s -X POST \"http://localhost:8000/api/user/auth/phone/login/\" -H \"Content-Type: application/json\" -d '{}')",
"Bash(git add:*)",
"Bash(git reset:*)"
],
"additionalDirectories": [
"C:\\Users\\admin\\.claude"
]
}
}

View File

@ -0,0 +1,340 @@
# 洛天依管理系统 - 功能清单与开发状态
> 生成时间2026-03-19已通过 API 实测验证)
---
## 一、整体架构
| 组件 | 技术栈 | 路径 |
|------|--------|------|
| 后端 API | Django + DRF + Channels + Daphne | `qy_lty/` |
| 管理后台前端 | Next.js 15 + React 19 + Tailwind + shadcn/ui | `qy-lty-admin/` |
| 数据库 | PostgreSQL阿里云 RDS | 远程 |
| 缓存/消息 | Redis阿里云 | 远程 |
---
## 二、功能模块状态总览
| 模块 | 前端页面 | 后端 API | 前后端对接 | 后端实测数据 | 状态 |
|------|----------|----------|------------|-------------|------|
| 邮箱登录 | ✅ | ✅ | ✅ 已对接 | ✅ 返回 token | ✅ 完成 |
| 手机登录 | ✅ UI | ✅ 已验证 | ❌ 未对接 | ✅ 接口可用 | ⚠️ 待对接 |
| 注册 | ✅ UI | ✅ 已验证 | ❌ 未对接 | ✅ 接口可用(返回参数校验) | ⚠️ 待对接 |
| 忘记密码 | ✅ UI | ✅ 已验证 | ❌ 未对接 | ✅ 接口可用 | ⚠️ 待对接 |
| 仪表盘 | ✅ UI | ❌ 无统计接口 | ❌ 硬编码数据 | — | ⚠️ 待开发 |
| 用户管理 | ✅ | ✅ | ✅ 已对接 | ✅ 5 条数据 | ✅ 完成 |
| 歌曲管理 | ✅ | ✅ | ✅ 已对接 | ✅ 9 条数据 | ✅ 完成 |
| 舞蹈管理 | ✅ UI | ✅ 已验证 | ❌ 前端用 mock | ✅ 5 条数据 | ⚠️ 待对接 |
| 服装管理 | ✅ UI | ✅ 已验证 | ❌ 前端用 mock | ✅ 11 条数据 | ⚠️ 待对接 |
| 道具管理 | ✅ UI | ✅ 已验证 | ❌ 前端用 mock | ✅ 3 条数据 | ⚠️ 待对接 |
| 家居装饰 | ✅ UI | ✅ 已验证 | ❌ 前端用 mock | ✅ 6 条数据 | ⚠️ 待对接 |
| 食物管理 | ✅ | ✅ | ✅ 已对接 | ✅ 5 条数据 | ✅ 完成 |
| 成就系统 | ✅ UI | ✅ 已验证 | ❌ 前端用 mock | ✅ 13 条数据 | ⚠️ 待对接 |
| 好感度 | ✅ UI | ✅ 已验证 | ❌ 前端用 mock | ✅ 接口可用(暂无数据) | ⚠️ 待对接 |
| AI 模型管理 | ✅ UI | ✅ 已验证 | ❌ 前端纯展示 | ✅ 1 个 Bot | ⚠️ 待对接 |
| 权限管理 | ✅ UI | ✅ 已验证 | ❌ 前端用 mock | ✅ 接口可用(暂无数据) | ⚠️ 待对接 |
| 系统设置 | ✅ UI | ❌ 无接口 | ❌ 无持久化 | — | ❌ 待开发 |
| 设备管理 | ❌ 无页面 | ✅ 已验证 | ❌ | ✅ 2 类型/20 设备 | ❌ 前端待开发 |
| 订阅管理 | ❌ 无页面 | ⚠️ 有模型无接口 | ❌ | — | ❌ 待开发 |
| 工作流 | ❌ 无页面 | ❌ 空模块 | ❌ | — | ❌ 待开发 |
---
## 三、已完成功能详细说明
### 1. 邮箱登录(✅ 完成)
- **前端**`/login` 页面,邮箱 + 密码表单
- **后端**`POST /api/v1/admin/login/` → 返回 token
- **使用方式**:输入邮箱和密码,登录后 token 自动存储到 localStorage 和 Cookie
- **测试账号**:邮箱 `111111@qq.com`,密码 `111111`
### 2. 用户管理(✅ 完成)
- **前端**`/users` 页面,完整 CRUD
- **后端**`/api/user/` ViewSet
- **功能**
- 用户列表(分页、搜索)
- 新增用户
- 编辑用户信息
- 删除用户
- 切换用户状态(启用/禁用)
### 3. 歌曲管理(✅ 完成)
- **前端**`/songs` 列表页 + `/songs/[id]` 详情页
- **后端**`/api/card/category/song/` + `/api/card/templates/`
- **功能**
- 歌曲列表(分页、搜索)
- 新增/编辑歌曲(含音频上传)
- 发布/取消发布
- 批次生成与管理
- 音频播放器(播放/暂停/音量控制)
### 4. 食物管理(✅ 完成)
- **前端**`/food` 列表页 + `/food/[id]` 详情页
- **后端**`/api/food/foods/`
- **功能**
- 食物列表(分页、搜索、分类筛选)
- 新增/编辑/删除食物
- 食物分类和稀有度管理
- 使用记录查看
---
## 四、待对接功能(前后端都有,需要连接)
以下模块前端 UI 已完成,后端 API 已存在,但前端页面使用的是 mock 硬编码数据,需要替换为真实 API 调用。
### 5. 舞蹈管理(⚠️ 待对接)
- **前端**`/dances` 页面,使用 `initialDances` 硬编码 5 条数据
- **后端 API**
- `GET /api/card/category/dance/` — 舞蹈列表
- `POST /api/card/templates/` — 创建
- `PATCH /api/card/templates/{id}/` — 更新
- `DELETE /api/card/templates/{id}/` — 删除
- **前端 API 模块**`lib/api/dances.ts` 已写好,页面未调用
- **工作量**:将页面中 mock 数据替换为 API 调用即可
### 6. 服装管理(⚠️ 待对接)
- **前端**`/outfits` 页面,内联硬编码数据
- **后端 API**`/api/card/category/clothing/` + `/api/card/templates/`
- **前端 API 模块**`lib/api/outfits.ts` 已写好
- **工作量**页面重构程度较大CRUD 交互不完整
### 7. 道具管理(⚠️ 待对接)
- **前端**`/props` 页面,使用 `initialProps` 硬编码数据
- **后端 API**`/api/card/category/prop/` + `/api/card/templates/`
- **前端 API 模块**`lib/api/props.ts` 已写好
- **工作量**:与舞蹈管理类似,替换 mock 数据
### 8. 家居装饰(⚠️ 待对接)
- **前端**`/home-decor` 页面,使用 `initialDecors` 硬编码数据
- **后端 API**`/api/card/category/decoration/` + `/api/card/templates/`
- **前端 API 模块**`lib/api/home-decor.ts` 已写好
- **工作量**:与舞蹈管理类似,替换 mock 数据
### 9. 成就系统(⚠️ 待对接)
- **前端**`/achievements` 页面,使用 `mockData` 硬编码 10 条数据
- **后端 API**
- `GET /api/achievement/achievements/` — 列表
- `POST /api/achievement/achievements/` — 创建
- `PATCH /api/achievement/achievements/{id}/` — 更新
- `DELETE /api/achievement/achievements/{id}/` — 删除
- **前端 API 模块**`lib/api/achievements.ts` 已写好
- **工作量**:替换 mock 数据 + 对齐数据结构
### 10. 好感度系统(⚠️ 待对接)
- **前端**`/affinity` 页面,使用 `initialRules``initialLevels` 硬编码
- **后端 API**
- `/api/user/affinity-rules/` — 好感度规则 CRUD
- `/api/user/affinity-levels/` — 好感度等级 CRUD
- **前端 API 模块**`lib/api/affinity.ts` 已写好
- **工作量**:替换 mock 数据
### 11. AI 模型管理(⚠️ 待对接)
- **前端**`/ai-model` 页面,纯展示页面(框架/微调/语音/知识库 tab
- **后端 API**
- `GET/POST/PATCH/DELETE /api/ai/bots/` — Bot CRUD
- **前端 API 模块**`lib/api/ai-models.ts` 已写好
- **工作量**:需要将展示页改为管理页,添加 CRUD 交互
### 12. 权限管理(⚠️ 待对接)
- **前端**`/permissions` 页面,使用 `initialRoles` 硬编码 5 个角色
- **后端 API**`/api/user/groups/` — Django Group CRUD
- **前端 API 模块**`lib/api/roles.ts` 已写好
- **工作量**:替换 mock 数据,权限矩阵需与后端权限体系对齐
### 13. 手机登录 / 注册 / 忘记密码(⚠️ 待对接)
- **前端**UI 表单已完成,但使用 `setTimeout` 模拟请求
- **后端**
- 手机登录:`POST /api/user/auth/phone/login/`
- 发送验证码:`POST /api/user/auth/phone/verify/`
- 注册:`POST /api/user/auth/register/`
- 重置密码:`POST /api/user/auth/password/reset/`
- **工作量**:前端对接后端已有接口
---
## 五、待开发功能(缺前端或缺后端)
### 14. 仪表盘数据(❌ 待开发)
- **现状**:前端 `/` 页面显示硬编码统计数据12,345 用户、5,732 活跃等)
- **需要**:后端开发统计聚合接口,前端对接
- **建议接口**
- `GET /api/v1/admin/dashboard/stats/` — 用户统计、卡片统计等
### 15. 系统设置(❌ 待开发)
- **现状**:前端 `/settings` 页面有基本/数据库/安全/通知 4 个 tab但无持久化
- **需要**:后端设计系统配置模型和接口
- **工作量**:前后端都需要开发
### 16. 设备管理(❌ 前端待开发)
- **后端已有完整 API**
- `/api/device/device-types/` — 设备类型 CRUD
- `/api/device/device-batches/` — 设备批次 CRUD
- `/api/device/devices/` — 设备 CRUD含批量创建
- `/api/device/user-devices/` — 用户设备绑定
- `/api/device/messages/` — 消息管理
- WebSocket`ws://domain/ws/device/{user_id}/`
- **需要**:开发前端管理页面
### 17. 订阅管理(❌ 前后端待开发)
- **现状**后端有模型定义SubscriptionPlan、Subscription、AddOnPackage 等),但无 API 接口
- **需要**:后端开发 API + 前端开发页面
### 18. 工作流系统(❌ 前后端待开发)
- **现状**:后端 `workflow_app` 为空模块,无模型无接口
- **需要**:完整设计和开发
### 19. 阿里云 VI视觉智能❌ 已禁用)
- **现状**:后端 `ali_vi_app` 有人脸相关接口,但在主 urls.py 中已注释禁用
- **接口**:人脸模板、人脸合成、美颜、美妆等
- **状态**:暂不需要管理后台
---
## 六、后端 API 完整清单
### 用户模块 `/api/user/`
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/` | 用户列表 |
| POST | `/` | 创建用户 |
| GET/PUT/PATCH/DELETE | `/{id}/` | 用户详情/更新/删除 |
| GET | `/info/` | 当前用户信息 |
| PUT/PATCH | `/update_profile/` | 更新个人资料 |
| POST | `/bind_phone/` | 绑定手机号 |
| GET/POST | `/groups/` | 角色列表/创建 |
| GET/PUT/DELETE | `/groups/{id}/` | 角色详情/更新/删除 |
| GET/POST | `/affinity-rules/` | 好感度规则列表/创建 |
| GET/PUT/DELETE | `/affinity-rules/{id}/` | 规则详情/更新/删除 |
| GET/POST | `/affinity-levels/` | 好感度等级列表/创建 |
| GET/PUT/DELETE | `/affinity-levels/{id}/` | 等级详情/更新/删除 |
### 认证模块 `/api/user/auth/`
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/login/` | 用户名密码登录 |
| POST | `/logout/` | 登出 |
| POST | `/register/` | 注册 |
| POST | `/email/login/` | 邮箱登录 |
| POST | `/phone/login/` | 手机号登录 |
| POST | `/phone/verify/` | 发送验证码 |
| POST | `/username/login/` | 用户名登录 |
| POST | `/mac/login/` | MAC 地址登录 |
| POST | `/password/reset/` | 重置密码 |
### 管理员模块 `/api/v1/admin/`
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/login/` | 管理员邮箱登录 |
| POST | `/logout/` | 管理员登出 |
### AI 模块 `/api/ai/`
| 方法 | 路径 | 说明 |
|------|------|------|
| GET/POST | `/bots/` | Bot 列表/创建 |
| GET/PUT/DELETE | `/bots/{id}/` | Bot 详情/更新/删除 |
| POST | `/chat/{bot_id}/` | 单轮对话 |
| POST | `/multichat/` | 多轮对话 |
### 卡片模块 `/api/card/`
| 方法 | 路径 | 说明 |
|------|------|------|
| GET/POST | `/templates/` | 模板列表/创建 |
| GET/PUT/DELETE | `/templates/{id}/` | 模板详情/更新/删除 |
| POST | `/templates/{id}/publish/` | 发布模板 |
| POST | `/templates/{id}/archive/` | 归档模板 |
| GET/POST | `/cards/` | 卡片列表/创建 |
| POST | `/cards/scan/` | 扫描卡片(需认证) |
| POST | `/cards/scan_public/` | 扫描卡片(公开) |
| POST | `/cards/use/` | 使用卡片 |
| GET/POST | `/batches/` | 批次列表/创建 |
| POST | `/batches/generate/` | 批量生成卡片 |
| GET | `/batches/{id}/export/` | 导出批次 |
| POST | `/batches/{id}/mark_produced/` | 标记生产完成 |
| POST | `/batches/{id}/publish/` | 发布批次 |
| GET | `/category/{category}/` | 按分类查询模板 |
| GET | `/user/cards/` | 用户卡片列表 |
### 食物模块 `/api/food/`
| 方法 | 路径 | 说明 |
|------|------|------|
| GET/POST | `/foods/` | 食物列表/创建 |
| GET/PUT/PATCH/DELETE | `/foods/{id}/` | 食物详情/更新/删除 |
| GET | `/foods/categories/` | 食物分类信息 |
| POST | `/use-food/` | 使用食物 |
| GET | `/my-foods/` | 我的食物 |
| GET | `/user-foods/` | 用户食物列表 |
| GET | `/food-stats/` | 食物统计 |
| GET | `/usage-logs/` | 使用记录 |
### 成就模块 `/api/achievement/`
| 方法 | 路径 | 说明 |
|------|------|------|
| GET/POST | `/achievements/` | 成就列表/创建 |
| GET/PUT/DELETE | `/achievements/{id}/` | 成就详情/更新/删除 |
| GET | `/achievements/{id}/check_achievement/` | 检查成就 |
| GET/POST | `/user-achievements/` | 用户成就列表/授予 |
| POST | `/user-achievements/check_and_grant/` | 检查并授予 |
| GET | `/user-achievements/stats/` | 成就统计 |
### 设备模块 `/api/device/`
| 方法 | 路径 | 说明 |
|------|------|------|
| CRUD | `/device-types/` | 设备类型管理 |
| CRUD | `/device-batches/` | 设备批次管理 |
| CRUD | `/devices/` | 设备管理 |
| POST | `/devices/batch_create/` | 批量创建设备 |
| CRUD | `/user-devices/` | 用户设备绑定 |
| GET | `/messages/` | 消息列表 |
| POST | `/rtc-token/` | 获取 RTC Token |
| POST | `/send_message_to_user/{user_id}/` | WebSocket 发消息 |
### 通用 `/api/common/`
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/upload/` | 文件上传 |
---
## 七、开发优先级建议
### 高优先级API 已有,仅需前端对接)
1. **舞蹈管理** — 前端 API 模块已写好,替换 mock 数据即可
2. **道具管理** — 同上
3. **家居装饰** — 同上
4. **成就系统** — 同上
5. **好感度系统** — 同上
6. **权限管理** — 同上
7. **手机登录/注册/忘记密码** — 前端 UI 已有,对接后端接口
### 中优先级(需要一定开发量)
8. **服装管理** — 前端页面结构需调整
9. **AI 模型管理** — 需要从展示页改为管理页
10. **仪表盘** — 需要后端开发统计接口
11. **设备管理** — 后端完整,需要开发前端页面
### 低优先级(需要较大开发量)
12. **系统设置** — 前后端都需要开发
13. **订阅管理** — 后端需要暴露 API前端需要开发页面
14. **工作流系统** — 完全空白,需要完整设计

View File

@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, use } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header"
@ -12,6 +12,7 @@ import Link from "next/link"
import { AddPrintBatchDialog } from "@/components/food/add-print-batch-dialog"
import { ExportCardsDialog } from "@/components/food/export-cards-dialog"
import { useToast } from "@/components/ui/use-toast"
import { isSuperUser } from "@/lib/api/auth"
import { getFood } from "@/lib/api/food"
import type { Food } from "@/components/food/food-detail-dialog"
@ -28,7 +29,8 @@ type FoodWithBatches = Food & {
}>
}
export default function FoodDetailPage({ params }: { params: { id: string } }) {
export default function FoodDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const { toast } = useToast()
const [food, setFood] = useState<FoodWithBatches | null>(null)
const [loading, setLoading] = useState(true)
@ -39,7 +41,7 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
try {
setLoading(true)
setError(null)
const response = await getFood(params.id)
const response = await getFood(id)
if (response.success && response.data) {
// 为演示目的,添加一些模拟的批次数据
@ -84,7 +86,7 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
useEffect(() => {
fetchFoodDetail()
}, [params.id])
}, [id])
if (loading) {
return (
@ -104,7 +106,7 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-gray-500 mb-6">
{error || `找不到ID为 ${params.id} 的食物`}
{error || `找不到ID为 ${id} 的食物`}
</p>
<Button asChild>
<Link href="/food">
@ -117,7 +119,21 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
)
}
const isPublished = food.status === "已发布"
const isPublished = food.status === "published" || food.status === "已发布"
// 显示名称映射
const foodTypeDisplayMap: Record<string, string> = {
fruit: "水果", vegetable: "蔬菜", meat: "肉类", seafood: "海鲜",
dairy: "乳制品", grain: "谷物", snack: "零食", drink: "饮品",
dessert: "甜点", spice: "调味品", other: "其他",
}
const rarityDisplayMap: Record<string, string> = {
common: "普通", uncommon: "非常见", rare: "稀有",
epic: "史诗", legendary: "传说", mythic: "神话",
}
const statusDisplayMap: Record<string, string> = {
draft: "草稿", published: "已发布", archived: "已归档",
}
return (
<DashboardShell>
@ -132,12 +148,12 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
</Button>
<DashboardHeader heading={food.name} text={`食物ID: ${food.id}`}>
<div className="flex space-x-2 ml-auto">
{!isPublished && (
{(!isPublished || isSuperUser()) && (
<Button
asChild
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg"
>
<Link href={`/food/edit/${params.id}`}>
<Link href={`/food/edit/${id}`}>
<Edit className="mr-2 h-4 w-4" />
</Link>
@ -163,9 +179,18 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
</CardHeader>
<CardContent className="flex justify-center">
<div className="relative w-full aspect-square max-w-[300px] rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
<img src={food.image || "/placeholder.svg"} alt={food.name} className="object-cover" />
{food.image && !food.image.includes("placeholder") ? (
<img src={food.image} alt={food.name} className="object-cover w-full h-full" />
) : (
<div className="flex flex-col items-center justify-center text-gray-400">
<svg className="h-16 w-16 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
<span className="text-sm"></span>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3">
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{food.status}</Badge>
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{statusDisplayMap[food.status] || food.status}</Badge>
</div>
</div>
</CardContent>
@ -179,11 +204,11 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{food.type}</p>
<p className="font-medium">{foodTypeDisplayMap[food.food_type] || food.food_type || "-"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{food.rarity}</p>
<p className="font-medium">{rarityDisplayMap[food.rarity] || food.rarity || "-"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
@ -191,11 +216,11 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{food.status}</p>
<p className="font-medium">{statusDisplayMap[food.status] || food.status}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{food.activatedCount}</p>
<p className="font-medium">{food.activatedCount || 0}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
@ -203,14 +228,18 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
</div>
<div className="col-span-2 space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{food.description}</p>
<p className="font-medium">{food.description || "暂无描述"}</p>
</div>
</div>
{isPublished && (
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start">
<AlertTriangle className="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700"></p>
<p className="text-sm text-amber-700">
{isSuperUser()
? "该食物已发布,您以超级管理员身份仍可编辑和删除。请谨慎操作。"
: "该食物已发布,基本属性不可修改。您仍可以增加印刷数量。"}
</p>
</div>
)}
</CardContent>

View File

@ -8,13 +8,15 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Search, Edit, Eye, Loader2 } from "lucide-react"
import { Search, Edit, Eye, Loader2, Archive } from "lucide-react"
import { AddFoodDialog } from "@/components/food/add-food-dialog"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
import { useToast } from "@/components/ui/use-toast"
import { isSuperUser } from "@/lib/api/auth"
import Link from "next/link"
import type { Food } from "@/components/food/food-detail-dialog"
import { getFoods, deleteFood as deleteFoodApi } from "@/lib/api/food"
import { getFoods, getFood, deleteFood as deleteFoodApi, publishFood, archiveFood } from "@/lib/api/food"
export default function FoodPage() {
const { toast } = useToast()
@ -35,6 +37,7 @@ export default function FoodPage() {
'published': '已发布',
'draft': '草稿',
'pending': '待审核',
'archived': '已归档',
}
return statusMap[status] || status
}
@ -117,9 +120,57 @@ export default function FoodPage() {
}
}
// 打开编辑对话框
const openEditDialog = (food: Food) => {
// 发布食物
const handlePublishFood = async (foodId: string, foodName: string) => {
try {
await publishFood(foodId)
fetchFoods(currentPage, searchTerm)
toast({
title: "发布成功",
description: `食物 "${foodName}" 已成功发布`,
})
} catch (error) {
console.error("发布食物失败:", error)
toast({
title: "发布失败",
description: "无法发布食物,请稍后重试",
variant: "destructive",
})
}
}
// 归档食物
const handleArchiveFood = async (foodId: string, foodName: string) => {
try {
await archiveFood(foodId)
fetchFoods(currentPage, searchTerm)
toast({
title: "归档成功",
description: `食物 "${foodName}" 已归档`,
})
} catch (error) {
console.error("归档食物失败:", error)
toast({
title: "归档失败",
description: "无法归档食物,请稍后重试",
variant: "destructive",
})
}
}
// 打开编辑对话框(先获取完整详情)
const openEditDialog = async (food: Food) => {
try {
const response = await getFood(String(food.id))
if (response.success && response.data) {
setSelectedFood(response.data)
} else {
setSelectedFood(food)
}
} catch (error) {
console.error("获取食物详情失败:", error)
setSelectedFood(food)
}
setIsEditDialogOpen(true)
}
@ -177,14 +228,16 @@ export default function FoodPage() {
<TableRow key={food.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">{food.id}</TableCell>
<TableCell className="font-medium text-pink-600">{food.name}</TableCell>
<TableCell>{food.food_type}</TableCell>
<TableCell>{food.rarity}</TableCell>
<TableCell>{food.food_type_display || food.food_type}</TableCell>
<TableCell>{food.rarity_display || food.rarity}</TableCell>
<TableCell>{food.releaseDate || "-"}</TableCell>
<TableCell>
<Badge
className={
(food.status === "published" || food.status === "已发布")
? "bg-green-500 hover:bg-green-600"
: (food.status === "archived" || food.status === "已归档")
? "bg-orange-500 hover:bg-orange-600"
: "bg-gray-500 hover:bg-gray-600"
}
>
@ -200,7 +253,30 @@ export default function FoodPage() {
</Link>
</Button>
{food.status !== "published" && food.status !== "已发布" && (
{/* 草稿状态:显示发布按钮 */}
{food.status === "draft" && (
<PublishConfirmationDialog
title="发布食物"
description="发布后该食物将可被用于卡牌生成"
itemName={food.name}
onPublish={() => handlePublishFood(food.id, food.name)}
/>
)}
{/* 已发布状态:显示归档按钮 */}
{food.status === "published" && (
<Button
variant="ghost"
size="icon"
className="hover:bg-orange-50 hover:text-orange-600"
title="归档"
onClick={() => handleArchiveFood(food.id, food.name)}
>
<Archive className="h-4 w-4" />
</Button>
)}
{(food.status !== "published" || isSuperUser()) && (
<>
<Button
variant="ghost"

View File

@ -1,146 +1,61 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
"use client"
import { useState, useEffect, use } from "react"
import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ArrowLeft, Edit, AlertTriangle, FileText } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ArrowLeft, Edit, AlertTriangle, Plus, Download, Loader2 } from "lucide-react"
import Link from "next/link"
import { AddPrintBatchDialog } from "@/components/home-decor/add-print-batch-dialog"
import { ExportCardsDialog } from "@/components/home-decor/export-cards-dialog"
import { isSuperUser } from "@/lib/api/auth"
import { getHomeDecor } from "@/lib/api/home-decor"
import type { HomeDecor } from "@/lib/api/types"
// Mock data for the home decor details
const decorData = {
DEC001: {
id: "DEC001",
name: "星空投影灯",
type: "灯饰",
rarity: "稀有",
description: "可以在房间内投影出美丽的星空,营造浪漫氛围。",
releaseDate: "2023-10-20",
status: "已发布",
activatedCount: 1342,
printedCount: 2500,
image: "/placeholder.svg?height=300&width=300",
batches: [
{
id: "B001",
date: "2023-09-01",
quantity: 1500,
startId: "DEC001-0001",
endId: "DEC001-1500",
activatedCount: 842,
},
{
id: "B002",
date: "2023-12-15",
quantity: 1000,
startId: "DEC001-1501",
endId: "DEC001-2500",
activatedCount: 500,
},
],
},
DEC002: {
id: "DEC002",
name: "音乐主题壁纸",
type: "墙饰",
rarity: "普通",
description: "以音乐元素为主题的壁纸,适合洛天依的房间装饰。",
releaseDate: "2023-11-05",
status: "已发布",
activatedCount: 2156,
printedCount: 3000,
image: "/placeholder.svg?height=300&width=300",
batches: [
{
id: "B003",
date: "2023-10-10",
quantity: 3000,
startId: "DEC002-0001",
endId: "DEC002-3000",
activatedCount: 2156,
},
],
},
DEC003: {
id: "DEC003",
name: "音符地毯",
type: "地饰",
rarity: "稀有",
description: "音符形状的地毯,踩上去会发出悦耳的音符声。",
releaseDate: "2023-12-15",
status: "已发布",
activatedCount: 987,
printedCount: 2000,
image: "/placeholder.svg?height=300&width=300",
batches: [
{
id: "B004",
date: "2023-11-20",
quantity: 2000,
startId: "DEC003-0001",
endId: "DEC003-2000",
activatedCount: 987,
},
],
},
DEC004: {
id: "DEC004",
name: "全息投影装置",
type: "科技装饰",
rarity: "传说",
description: "可以投影出洛天依的全息影像,实现虚拟互动。",
releaseDate: "2024-01-20",
status: "已发布",
activatedCount: 456,
printedCount: 1000,
image: "/placeholder.svg?height=300&width=300",
batches: [
{
id: "B005",
date: "2024-01-05",
quantity: 1000,
startId: "DEC004-0001",
endId: "DEC004-1000",
activatedCount: 456,
},
],
},
DEC005: {
id: "DEC005",
name: "樱花主题家具套装",
type: "家具套装",
rarity: "史诗",
description: "以樱花为主题的家具套装,包含床、桌椅、柜子等多件家具。",
releaseDate: "",
status: "未发布",
activatedCount: 0,
printedCount: 1500,
image: "/placeholder.svg?height=300&width=300",
batches: [
{
id: "B006",
date: "2024-02-10",
quantity: 1500,
startId: "DEC005-0001",
endId: "DEC005-1500",
activatedCount: 0,
},
],
},
export default function HomeDecorDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [decor, setDecor] = useState<HomeDecor | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchDecor = async () => {
try {
setLoading(true)
setError(null)
const data = await getHomeDecor(id)
setDecor(data)
} catch (err) {
console.error("获取家居装饰详情失败:", err)
setError(`找不到ID为 ${id} 的家居装饰`)
} finally {
setLoading(false)
}
}
fetchDecor()
}, [id])
if (loading) {
return (
<DashboardShell>
<div className="flex items-center justify-center h-[60vh]">
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
<span className="ml-2 text-muted-foreground">...</span>
</div>
</DashboardShell>
)
}
export default function HomeDecorDetailPage({ params }: { params: { id: string } }) {
const decor = decorData[params.id as keyof typeof decorData]
if (!decor) {
if (error || !decor) {
return (
<DashboardShell>
<div className="flex flex-col items-center justify-center h-[60vh]">
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-gray-500 mb-6">ID为 {params.id} </p>
<p className="text-gray-500 mb-6">{error || `找不到ID为 ${id} 的家居装饰`}</p>
<Button asChild>
<Link href="/home-decor">
<ArrowLeft className="mr-2 h-4 w-4" />
@ -153,6 +68,9 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
}
const isPublished = decor.status === "已发布"
const printedCount = decor.batchesCount || 0
const activatedCount = decor.activeCardsCount || 0
const activationRate = printedCount > 0 ? Math.round((activatedCount / printedCount) * 100) : 0
return (
<DashboardShell>
@ -167,12 +85,12 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
</Button>
<DashboardHeader heading={decor.name} text={`家居装饰ID: ${decor.id}`}>
<div className="flex space-x-2 ml-auto">
{!isPublished && (
{(!isPublished || isSuperUser()) && (
<Button
asChild
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg"
>
<Link href={`/home-decor/edit/${params.id}`}>
<Link href={`/home-decor/edit/${id}`}>
<Edit className="mr-2 h-4 w-4" />
</Link>
@ -183,14 +101,14 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
</DashboardHeader>
</div>
<Tabs defaultValue="details" className="space-y-4">
<TabsList>
<Tabs defaultValue="details" className="w-full">
<TabsList className="grid w-full md:w-auto grid-cols-2 md:grid-cols-3 mb-4">
<TabsTrigger value="details"></TabsTrigger>
<TabsTrigger value="batches"></TabsTrigger>
<TabsTrigger value="analytics"></TabsTrigger>
</TabsList>
<TabsContent value="details" className="space-y-4">
<TabsContent value="details" className="space-y-6">
<div className="grid gap-6 md:grid-cols-3">
<Card className="md:col-span-1 border-none shadow-lg bg-white">
<CardHeader>
@ -198,9 +116,18 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
</CardHeader>
<CardContent className="flex justify-center">
<div className="relative w-full aspect-square max-w-[300px] rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
<img src={decor.image || "/placeholder.svg"} alt={decor.name} className="object-cover" />
{decor.imageUrl && !decor.imageUrl.includes("placeholder") ? (
<img src={decor.imageUrl} alt={decor.name} className="object-cover w-full h-full" />
) : (
<div className="flex flex-col items-center justify-center text-gray-400">
<svg className="h-16 w-16 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
<span className="text-sm"></span>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3">
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{decor.status}</Badge>
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{decor.status || "未发布"}</Badge>
</div>
</div>
</CardContent>
@ -213,39 +140,44 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.type}</p>
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.category || "-"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.rarity}</p>
<p className="font-medium">{decor.rarity || "-"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.releaseDate || "尚未发布"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.status}</p>
<p className="font-medium">{decor.publishedAt || "尚未发布"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.activatedCount}</p>
<p className="font-medium">{activatedCount}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.printedCount}</p>
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.createdAt || "-"}</p>
</div>
<div className="col-span-2 space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.description}</p>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{activationRate}%</p>
</div>
</div>
<div className="mt-6">
<p className="text-sm font-medium text-gray-500 mb-2"></p>
<p className="text-gray-700">{decor.description || "暂无描述"}</p>
</div>
{isPublished && (
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start">
<AlertTriangle className="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700"></p>
<p className="text-sm text-amber-700">
{isSuperUser()
? "该家居装饰已发布,您以超级管理员身份仍可编辑和删除。请谨慎操作。"
: "该家居装饰已发布,基本属性不可修改。您仍可以增加印刷数量。"}
</p>
</div>
)}
</CardContent>
@ -253,11 +185,11 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
</div>
</TabsContent>
<TabsContent value="batches" className="space-y-4">
<TabsContent value="batches" className="space-y-6">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>ID</CardDescription>
</div>
<AddPrintBatchDialog decorId={decor.id} isPublished={isPublished} />
@ -270,57 +202,79 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-right text-sm font-medium text-gray-500"></th>
</tr>
</thead>
<tbody>
{decor.batches.map((batch) => (
<tr key={batch.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 text-sm font-medium">{batch.id}</td>
<td className="py-3 px-4 text-sm">{batch.date}</td>
<td className="py-3 px-4 text-sm">{batch.quantity}</td>
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.startId}</td>
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.endId}</td>
<td className="py-3 px-4 text-sm">{batch.activatedCount}</td>
<td className="py-3 px-4 text-right">
<Button variant="ghost" size="sm" className="h-8 hover:bg-blue-50 hover:text-blue-600">
<FileText className="h-4 w-4 mr-1" />
ID
</Button>
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics" className="space-y-4">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-green-50">
<Card className="border-none shadow-lg bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>使</CardDescription>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500"></p>
</div>
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500"></p>
</div>
<div className="md:col-span-2 h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500"></p>
</div>
<div className="flex flex-wrap gap-4">
<Button variant="outline" className="border-blue-200 hover:bg-blue-50 hover:text-blue-700">
<Download className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" className="border-purple-200 hover:bg-purple-50 hover:text-purple-700">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics" className="space-y-6">
<Card className="border-none shadow-lg bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center bg-gray-50 rounded-lg">
<p className="text-gray-500"></p>
</div>
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-2">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-green-50">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px] flex items-center justify-center bg-gray-50 rounded-lg">
<p className="text-gray-500"></p>
</div>
</CardContent>
</Card>
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px] flex items-center justify-center bg-gray-50 rounded-lg">
<p className="text-gray-500"></p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</DashboardShell>
)

View File

@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect, useCallback } from "react"
import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button"
@ -8,96 +8,90 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Search, Edit, Eye } from "lucide-react"
import { Search, Edit, Eye, Loader2, Archive } from "lucide-react"
import { AddHomeDecorDialog } from "@/components/home-decor/add-home-decor-dialog"
import type { HomeDecor as ComponentHomeDecor } from "@/components/home-decor/home-decor-detail-dialog"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
import { useToast } from "@/components/ui/use-toast"
import { isSuperUser } from "@/lib/api/auth"
import { getHomeDecors, deleteHomeDecor, publishHomeDecor, archiveHomeDecor } from "@/lib/api/home-decor"
import type { HomeDecor } from "@/lib/api/types"
import Link from "next/link"
import type { HomeDecor } from "@/components/home-decor/home-decor-detail-dialog"
// 初始家居装饰数据
const initialDecors: HomeDecor[] = [
{
id: "DEC001",
name: "星空投影灯",
type: "灯饰",
rarity: "稀有",
description: "可以在房间内投影出美丽的星空,营造浪漫氛围。",
releaseDate: "2023-10-20",
status: "已发布",
activatedCount: 1342,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "DEC002",
name: "音乐主题壁纸",
type: "墙饰",
rarity: "普通",
description: "以音乐元素为主题的壁纸,适合洛天依的房间装饰。",
releaseDate: "2023-11-05",
status: "已发布",
activatedCount: 2156,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "DEC003",
name: "音符地毯",
type: "地饰",
rarity: "稀有",
description: "音符形状的地毯,踩上去会发出悦耳的音符声。",
releaseDate: "2023-12-15",
status: "已发布",
activatedCount: 987,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "DEC004",
name: "全息投影装置",
type: "科技装饰",
rarity: "传说",
description: "可以投影出洛天依的全息影像,实现虚拟互动。",
releaseDate: "2024-01-20",
status: "已发布",
activatedCount: 456,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "DEC005",
name: "樱花主题家具套装",
type: "家具套装",
rarity: "史诗",
description: "以樱花为主题的家具套装,包含床、桌椅、柜子等多件家具。",
releaseDate: "",
status: "未发布",
activatedCount: 0,
image: "/placeholder.svg?height=300&width=300",
},
]
// 格式化日期时间
function formatDate(dateStr: string): string {
if (!dateStr) return ""
try {
return new Date(dateStr).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
} catch {
return dateStr
}
}
// 将 API HomeDecor 转换为组件显示用的 HomeDecor
function toDisplayDecor(decor: HomeDecor): ComponentHomeDecor {
return {
id: decor.id,
name: decor.name,
type: decor.category || "",
rarity: decor.rarity || "",
description: decor.description || "",
releaseDate: formatDate(decor.publishedAt || decor.createdAt || ""),
status: decor.status || "未发布",
activatedCount: decor.activeCardsCount || 0,
image: decor.imageUrl || "/placeholder.svg?height=300&width=300",
}
}
export default function HomeDecorPage() {
const { toast } = useToast()
const [decors, setDecors] = useState<HomeDecor[]>(initialDecors)
const [decors, setDecors] = useState<ComponentHomeDecor[]>([])
const [searchTerm, setSearchTerm] = useState("")
const [currentPage, setCurrentPage] = useState(1)
const [selectedDecor, setSelectedDecor] = useState<HomeDecor | null>(null)
const [totalItems, setTotalItems] = useState(0)
const [selectedDecor, setSelectedDecor] = useState<ComponentHomeDecor | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [loading, setLoading] = useState(true)
const itemsPerPage = 5
const itemsPerPage = 10
// 过滤和分页
const filteredDecors = decors.filter(
(decor) =>
decor.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
decor.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
decor.type.toLowerCase().includes(searchTerm.toLowerCase()),
)
// 从后端获取家居装饰列表
const fetchDecors = useCallback(async () => {
try {
setLoading(true)
const response = await getHomeDecors({
page: currentPage,
pageSize: itemsPerPage,
search: searchTerm || undefined,
})
setDecors(response.items.map(toDisplayDecor))
setTotalItems(response.total)
} catch (error) {
console.error("获取家居装饰列表失败:", error)
toast({
title: "获取失败",
description: "无法获取家居装饰列表,请稍后重试",
variant: "destructive",
})
} finally {
setLoading(false)
}
}, [currentPage, searchTerm, toast])
const totalPages = Math.ceil(filteredDecors.length / itemsPerPage)
const paginatedDecors = filteredDecors.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
useEffect(() => {
fetchDecors()
}, [fetchDecors])
const totalPages = Math.ceil(totalItems / itemsPerPage)
// 处理添加家居装饰
const handleAddDecor = (newDecor: HomeDecor) => {
setDecors((prevDecors) => [...prevDecors, newDecor])
const handleAddDecor = (newDecor: ComponentHomeDecor) => {
fetchDecors()
toast({
title: "添加成功",
description: `家居装饰 ${newDecor.name} 已成功添加`,
@ -105,8 +99,8 @@ export default function HomeDecorPage() {
}
// 处理编辑家居装饰
const handleEditDecor = (updatedDecor: HomeDecor) => {
setDecors((prevDecors) => prevDecors.map((decor) => (decor.id === updatedDecor.id ? updatedDecor : decor)))
const handleEditDecor = (updatedDecor: ComponentHomeDecor) => {
fetchDecors()
setSelectedDecor(null)
setIsEditDialogOpen(false)
toast({
@ -117,19 +111,64 @@ export default function HomeDecorPage() {
// 处理删除家居装饰
const handleDeleteDecor = async (decorId: string) => {
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 1000))
setDecors((prevDecors) => prevDecors.filter((decor) => decor.id !== decorId))
try {
await deleteHomeDecor(decorId)
await fetchDecors()
toast({
title: "删除成功",
description: "家居装饰已成功删除",
variant: "destructive",
})
} catch (error) {
console.error("删除家居装饰失败:", error)
toast({
title: "删除失败",
description: "无法删除家居装饰,请稍后重试",
variant: "destructive",
})
}
}
// 发布家居装饰
const handlePublishDecor = async (decorId: string, decorName: string) => {
try {
await publishHomeDecor(decorId)
await fetchDecors()
toast({
title: "发布成功",
description: `家居装饰 "${decorName}" 已成功发布`,
})
} catch (error) {
console.error("发布家居装饰失败:", error)
toast({
title: "发布失败",
description: "无法发布家居装饰,请稍后重试",
variant: "destructive",
})
}
}
// 归档家居装饰
const handleArchiveDecor = async (decorId: string, decorName: string) => {
try {
await archiveHomeDecor(decorId)
await fetchDecors()
toast({
title: "归档成功",
description: `家居装饰 "${decorName}" 已归档`,
})
} catch (error) {
console.error("归档家居装饰失败:", error)
toast({
title: "归档失败",
description: "无法归档家居装饰,请稍后重试",
variant: "destructive",
})
}
}
// 打开编辑对话框
const openEditDialog = (decor: HomeDecor) => {
const openEditDialog = (decor: ComponentHomeDecor) => {
setSelectedDecor(decor)
setIsEditDialogOpen(true)
}
@ -151,7 +190,7 @@ export default function HomeDecorPage() {
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setCurrentPage(1) // 重置到第一页
setCurrentPage(1)
}}
/>
</div>
@ -161,14 +200,18 @@ export default function HomeDecorPage() {
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
<CardHeader>
<CardTitle className="text-xl font-bold flex items-center">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
</span>
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600"></span>
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
<span className="ml-2 text-muted-foreground">...</span>
</div>
) : (
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
@ -183,7 +226,7 @@ export default function HomeDecorPage() {
</TableRow>
</TableHeader>
<TableBody>
{paginatedDecors.map((decor) => (
{decors.map((decor) => (
<TableRow key={decor.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">{decor.id}</TableCell>
<TableCell className="font-medium text-pink-600">{decor.name}</TableCell>
@ -193,7 +236,11 @@ export default function HomeDecorPage() {
<TableCell>
<Badge
className={
decor.status === "已发布" ? "bg-green-500 hover:bg-green-600" : "bg-gray-500 hover:bg-gray-600"
decor.status === "已发布"
? "bg-green-500 hover:bg-green-600"
: decor.status === "已归档"
? "bg-orange-500 hover:bg-orange-600"
: "bg-gray-500 hover:bg-gray-600"
}
>
{decor.status}
@ -208,7 +255,30 @@ export default function HomeDecorPage() {
</Link>
</Button>
{decor.status !== "已发布" && (
{/* 草稿状态:显示发布按钮 */}
{decor.status === "草稿" && (
<PublishConfirmationDialog
title="发布家居装饰"
description="发布后该家居装饰将可被用于卡牌生成"
itemName={decor.name}
onPublish={() => handlePublishDecor(decor.id, decor.name)}
/>
)}
{/* 已发布状态:显示归档按钮 */}
{decor.status === "已发布" && (
<Button
variant="ghost"
size="icon"
className="hover:bg-orange-50 hover:text-orange-600"
title="归档"
onClick={() => handleArchiveDecor(decor.id, decor.name)}
>
<Archive className="h-4 w-4" />
</Button>
)}
{(decor.status !== "已发布" || isSuperUser()) && (
<>
<Button
variant="ghost"
@ -231,7 +301,7 @@ export default function HomeDecorPage() {
</TableRow>
))}
{paginatedDecors.length === 0 && (
{decors.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
@ -240,11 +310,12 @@ export default function HomeDecorPage() {
)}
</TableBody>
</Table>
)}
</CardContent>
<CardFooter className="flex justify-between">
<div className="text-sm text-muted-foreground">
{paginatedDecors.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
{Math.min(currentPage * itemsPerPage, filteredDecors.length)} {filteredDecors.length}
{decors.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
{Math.min(currentPage * itemsPerPage, totalItems)} {totalItems}
</div>
<div className="flex items-center space-x-2">
<Button

View File

@ -35,8 +35,8 @@ export default function LoginPage() {
const response = await emailLogin(email, password)
console.log(response)
// 保存登录凭证
saveAuthToken(response.data.token)
// 保存登录凭证(包含角色信息)
saveAuthToken(response.data.token, response.data.is_superuser, response.data.role)
// 设置登录状态
localStorage.setItem("isLoggedIn", "true")

View File

@ -1,99 +1,61 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
"use client"
import { useState, useEffect, use } from "react"
import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ArrowLeft, Edit, AlertTriangle, FileText } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ArrowLeft, Edit, AlertTriangle, FileText, Plus, Download, Loader2 } from "lucide-react"
import Link from "next/link"
import { AddPrintBatchDialog } from "@/components/outfits/add-print-batch-dialog"
import { ExportCardsDialog } from "@/components/outfits/export-cards-dialog"
import { isSuperUser } from "@/lib/api/auth"
import { getOutfit } from "@/lib/api/outfits"
import type { Outfit } from "@/lib/api/types"
// Mock data for the outfit details
const outfitData = {
OFT001: {
id: "OFT001",
name: "经典原创服装",
type: "常规",
rarity: "稀有",
description: "洛天依的经典原创服装,简约而不失时尚,适合各种场合穿着。",
releaseDate: "2023-10-15",
status: "已发布",
activatedCount: 1245,
printedCount: 2000,
image: "/placeholder.svg?height=300&width=300",
batches: [
{ id: "B001", date: "2023-09-01", quantity: 1000, startId: "OFT001-0001", endId: "OFT001-1000" },
{ id: "B002", date: "2023-12-15", quantity: 1000, startId: "OFT001-1001", endId: "OFT001-2000" },
],
},
OFT002: {
id: "OFT002",
name: "夏日泳装",
type: "季节限定",
rarity: "史诗",
description: "专为夏季设计的清凉泳装,让洛天依在夏日活动中更加亮眼。",
releaseDate: "2023-06-01",
status: "已发布",
activatedCount: 876,
printedCount: 1500,
image: "/placeholder.svg?height=300&width=300",
batches: [{ id: "B003", date: "2023-05-10", quantity: 1500, startId: "OFT002-0001", endId: "OFT002-1500" }],
},
OFT003: {
id: "OFT003",
name: "冬日圣诞服",
type: "节日限定",
rarity: "史诗",
description: "圣诞节特别设计的节日服装,温暖而喜庆,让洛天依陪你度过欢乐的圣诞。",
releaseDate: "2023-12-01",
status: "已发布",
activatedCount: 1032,
printedCount: 2000,
image: "/placeholder.svg?height=300&width=300",
batches: [{ id: "B004", date: "2023-11-15", quantity: 2000, startId: "OFT003-0001", endId: "OFT003-2000" }],
},
OFT004: {
id: "OFT004",
name: "校园制服",
type: "常规",
rarity: "稀有",
description: "清新可爱的校园风格制服,展现洛天依青春活力的一面。",
releaseDate: "2024-01-15",
status: "已发布",
activatedCount: 1567,
printedCount: 3000,
image: "/placeholder.svg?height=300&width=300",
batches: [
{ id: "B005", date: "2024-01-05", quantity: 2000, startId: "OFT004-0001", endId: "OFT004-2000" },
{ id: "B006", date: "2024-02-10", quantity: 1000, startId: "OFT004-2001", endId: "OFT004-3000" },
],
},
OFT005: {
id: "OFT005",
name: "演唱会礼服",
type: "特别版",
rarity: "传说",
description: "为重要演唱会设计的华丽礼服,尽显洛天依的优雅与魅力。",
releaseDate: "",
status: "未发布",
activatedCount: 0,
printedCount: 1000,
image: "/placeholder.svg?height=300&width=300",
batches: [{ id: "B007", date: "2024-03-20", quantity: 1000, startId: "OFT005-0001", endId: "OFT005-1000" }],
},
export default function OutfitDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [outfit, setOutfit] = useState<Outfit | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchOutfit = async () => {
try {
setLoading(true)
setError(null)
const data = await getOutfit(id)
setOutfit(data)
} catch (err) {
console.error("获取服装详情失败:", err)
setError(`找不到ID为 ${id} 的服装`)
} finally {
setLoading(false)
}
}
fetchOutfit()
}, [id])
if (loading) {
return (
<DashboardShell>
<div className="flex items-center justify-center h-[60vh]">
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
<span className="ml-2 text-muted-foreground">...</span>
</div>
</DashboardShell>
)
}
export default function OutfitDetailPage({ params }: { params: { id: string } }) {
const outfit = outfitData[params.id as keyof typeof outfitData]
if (!outfit) {
if (error || !outfit) {
return (
<DashboardShell>
<div className="flex flex-col items-center justify-center h-[60vh]">
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-gray-500 mb-6">ID为 {params.id} </p>
<p className="text-gray-500 mb-6">{error || `找不到ID为 ${id} 的服装`}</p>
<Button asChild>
<Link href="/outfits">
<ArrowLeft className="mr-2 h-4 w-4" />
@ -106,10 +68,13 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
}
const isPublished = outfit.status === "已发布"
const printedCount = outfit.batchesCount || 0
const activatedCount = outfit.activeCardsCount || 0
const activationRate = printedCount > 0 ? Math.round((activatedCount / printedCount) * 100) : 0
return (
<DashboardShell>
<div className="absolute top-0 right-0 w-1/2 h-48 bg-gradient-to-bl from-pink-200 via-purple-200 to-transparent opacity-20 rounded-bl-full" />
<div className="absolute top-0 right-0 w-1/2 h-48 bg-gradient-to-bl from-purple-200 via-pink-200 to-transparent opacity-20 rounded-bl-full" />
<div className="flex items-center mb-6">
<Button variant="ghost" size="sm" className="mr-4" asChild>
@ -120,12 +85,12 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
</Button>
<DashboardHeader heading={outfit.name} text={`服装ID: ${outfit.id}`}>
<div className="flex space-x-2 ml-auto">
{!isPublished && (
{(!isPublished || isSuperUser()) && (
<Button
asChild
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg"
>
<Link href={`/outfits/edit/${params.id}`}>
<Link href={`/outfits/edit/${id}`}>
<Edit className="mr-2 h-4 w-4" />
</Link>
@ -136,14 +101,14 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
</DashboardHeader>
</div>
<Tabs defaultValue="details" className="space-y-4">
<TabsList>
<Tabs defaultValue="details" className="w-full">
<TabsList className="grid w-full md:w-auto grid-cols-2 md:grid-cols-3 mb-4">
<TabsTrigger value="details"></TabsTrigger>
<TabsTrigger value="batches"></TabsTrigger>
<TabsTrigger value="analytics"></TabsTrigger>
</TabsList>
<TabsContent value="details" className="space-y-4">
<TabsContent value="details" className="space-y-6">
<div className="grid gap-6 md:grid-cols-3">
<Card className="md:col-span-1 border-none shadow-lg bg-white">
<CardHeader>
@ -151,9 +116,18 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
</CardHeader>
<CardContent className="flex justify-center">
<div className="relative w-full aspect-square max-w-[300px] rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
<img src={outfit.image || "/placeholder.svg"} alt={outfit.name} className="object-cover" />
{outfit.imageUrl && !outfit.imageUrl.includes("placeholder") ? (
<img src={outfit.imageUrl} alt={outfit.name} className="object-cover w-full h-full" />
) : (
<div className="flex flex-col items-center justify-center text-gray-400">
<svg className="h-16 w-16 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
<span className="text-sm"></span>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3">
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{outfit.status}</Badge>
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{outfit.status || "未发布"}</Badge>
</div>
</div>
</CardContent>
@ -167,38 +141,43 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{outfit.type}</p>
<p className="font-medium">{outfit.category || "-"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{outfit.rarity}</p>
<p className="font-medium">{outfit.rarity || "-"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{outfit.releaseDate || "尚未发布"}</p>
<p className="font-medium">{outfit.publishedAt || "尚未发布"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{outfit.activatedCount}</p>
<p className="font-medium">{activatedCount}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{outfit.printedCount}</p>
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{outfit.createdAt || "-"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{outfit.printedCount - outfit.activatedCount}</p>
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{activationRate}%</p>
</div>
<div className="col-span-2 space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="text-gray-700">{outfit.description}</p>
</div>
<div className="mt-6">
<p className="text-sm font-medium text-gray-500 mb-2"></p>
<p className="text-gray-700">{outfit.description || "暂无描述"}</p>
</div>
{isPublished && (
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start">
<AlertTriangle className="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700"></p>
<p className="text-sm text-amber-700">
{isSuperUser()
? "该服装已发布,您以超级管理员身份仍可编辑和删除。请谨慎操作。"
: "该服装已发布,基本属性不可修改。您仍可以增加印刷数量。"}
</p>
</div>
)}
</CardContent>
@ -206,11 +185,11 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
</div>
</TabsContent>
<TabsContent value="batches" className="space-y-4">
<TabsContent value="batches" className="space-y-6">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>ID</CardDescription>
</div>
<AddPrintBatchDialog outfitId={outfit.id} isPublished={isPublished} />
@ -223,55 +202,79 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-right text-sm font-medium text-gray-500"></th>
</tr>
</thead>
<tbody>
{outfit.batches.map((batch) => (
<tr key={batch.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 text-sm font-medium">{batch.id}</td>
<td className="py-3 px-4 text-sm">{batch.date}</td>
<td className="py-3 px-4 text-sm">{batch.quantity}</td>
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.startId}</td>
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.endId}</td>
<td className="py-3 px-4 text-right">
<Button variant="ghost" size="sm" className="h-8 hover:bg-blue-50 hover:text-blue-600">
<FileText className="h-4 w-4 mr-1" />
ID
</Button>
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics" className="space-y-4">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-green-50">
<Card className="border-none shadow-lg bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>使</CardDescription>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500"></p>
</div>
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500"></p>
</div>
<div className="md:col-span-2 h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500"></p>
</div>
<div className="flex flex-wrap gap-4">
<Button variant="outline" className="border-blue-200 hover:bg-blue-50 hover:text-blue-700">
<Download className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" className="border-purple-200 hover:bg-purple-50 hover:text-purple-700">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics" className="space-y-6">
<Card className="border-none shadow-lg bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center bg-gray-50 rounded-lg">
<p className="text-gray-500"></p>
</div>
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-2">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-green-50">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px] flex items-center justify-center bg-gray-50 rounded-lg">
<p className="text-gray-500"></p>
</div>
</CardContent>
</Card>
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px] flex items-center justify-center bg-gray-50 rounded-lg">
<p className="text-gray-500"></p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</DashboardShell>
)

View File

@ -1,166 +1,173 @@
"use client"
import { useState } from "react"
import { useState, useEffect, useCallback } from "react"
import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Search, Edit, Eye, Sparkles, Plus } from "lucide-react"
import { Search, Edit, Eye, Loader2, Archive } from "lucide-react"
import { AddOutfitDialog } from "@/components/outfits/add-outfit-dialog"
import type { DisplayOutfit } from "@/components/outfits/add-outfit-dialog"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
import { useToast } from "@/components/ui/use-toast"
import { isSuperUser } from "@/lib/api/auth"
import { getOutfits, deleteOutfit, publishOutfit, archiveOutfit } from "@/lib/api/outfits"
import type { Outfit } from "@/lib/api/types"
import Link from "next/link"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
function formatDate(dateStr: string): string {
if (!dateStr) return ""
try {
return new Date(dateStr).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
} catch {
return dateStr
}
}
function toDisplayOutfit(outfit: Outfit): DisplayOutfit {
return {
id: outfit.id,
name: outfit.name,
type: outfit.category || "",
rarity: outfit.rarity || "",
description: outfit.description || "",
releaseDate: formatDate(outfit.publishedAt || outfit.createdAt || ""),
status: outfit.status || "未发布",
activatedCount: outfit.activeCardsCount || 0,
image: outfit.imageUrl || "",
}
}
export default function OutfitsPage() {
// 直接在页面中实现对话框
const [open, setOpen] = useState(false)
const [step, setStep] = useState(1)
const [outfitType, setOutfitType] = useState("")
const [rarity, setRarity] = useState("")
const [printQuantity, setPrintQuantity] = useState(1000)
const [isSubmitting, setIsSubmitting] = useState(false)
const { toast } = useToast()
const [outfits, setOutfits] = useState<DisplayOutfit[]>([])
const [searchTerm, setSearchTerm] = useState("")
const [currentPage, setCurrentPage] = useState(1)
const [totalItems, setTotalItems] = useState(0)
const [selectedOutfit, setSelectedOutfit] = useState<DisplayOutfit | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [loading, setLoading] = useState(true)
const handleSubmit = async () => {
setIsSubmitting(true)
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 1500))
setIsSubmitting(false)
setOpen(false)
// 重置表单
setStep(1)
const itemsPerPage = 10
const fetchOutfits = useCallback(async () => {
try {
setLoading(true)
const response = await getOutfits({
page: currentPage,
pageSize: itemsPerPage,
search: searchTerm || undefined,
})
setOutfits(response.items.map(toDisplayOutfit))
setTotalItems(response.total)
} catch (error) {
console.error("获取服装列表失败:", error)
toast({
title: "获取失败",
description: "无法获取服装列表,请稍后重试",
variant: "destructive",
})
} finally {
setLoading(false)
}
}, [currentPage, searchTerm, toast])
useEffect(() => {
fetchOutfits()
}, [fetchOutfits])
const totalPages = Math.ceil(totalItems / itemsPerPage)
const handleAddOutfit = (newOutfit: DisplayOutfit) => {
fetchOutfits()
toast({
title: "添加成功",
description: `服装 ${newOutfit.name} 已成功添加`,
})
}
const handleNext = () => {
setStep(step + 1)
const handleEditOutfit = (updatedOutfit: DisplayOutfit) => {
fetchOutfits()
setSelectedOutfit(null)
setIsEditDialogOpen(false)
toast({
title: "更新成功",
description: `服装 ${updatedOutfit.name} 已成功更新`,
})
}
const handleBack = () => {
setStep(step - 1)
const handleDeleteOutfit = async (outfitId: string) => {
try {
await deleteOutfit(outfitId)
await fetchOutfits()
toast({
title: "删除成功",
description: "服装已成功删除",
variant: "destructive",
})
} catch (error) {
console.error("删除服装失败:", error)
toast({
title: "删除失败",
description: "无法删除服装,请稍后重试",
variant: "destructive",
})
}
}
const handlePublishOutfit = async (outfitId: string, outfitName: string) => {
try {
await publishOutfit(outfitId)
await fetchOutfits()
toast({
title: "发布成功",
description: `服装 "${outfitName}" 已成功发布`,
})
} catch (error) {
console.error("发布服装失败:", error)
toast({
title: "发布失败",
description: "无法发布服装,请稍后重试",
variant: "destructive",
})
}
}
const handleArchiveOutfit = async (outfitId: string, outfitName: string) => {
try {
await archiveOutfit(outfitId)
await fetchOutfits()
toast({
title: "归档成功",
description: `服装 "${outfitName}" 已归档`,
})
} catch (error) {
console.error("归档服装失败:", error)
toast({
title: "归档失败",
description: "无法归档服装,请稍后重试",
variant: "destructive",
})
}
}
const openEditDialog = (outfit: DisplayOutfit) => {
setSelectedOutfit(outfit)
setIsEditDialogOpen(true)
}
return (
<DashboardShell>
<DashboardHeader heading="服装管理" text="管理洛天依的服装卡牌">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg">
<Plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
</DialogTitle>
<DialogDescription>ID</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-right">
<span className="text-red-500">*</span>
</Label>
<Input
id="name"
placeholder="输入服装名称"
className="border-gray-300 focus-visible:ring-pink-500"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="type" className="text-right">
<span className="text-red-500">*</span>
</Label>
<Select value={outfitType} onValueChange={setOutfitType} required>
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
<SelectValue placeholder="选择服装类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="regular"></SelectItem>
<SelectItem value="seasonal"></SelectItem>
<SelectItem value="festival"></SelectItem>
<SelectItem value="special"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="rarity" className="text-right">
<span className="text-red-500">*</span>
</Label>
<Select value={rarity} onValueChange={setRarity} required>
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
<SelectValue placeholder="选择稀有度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="common"></SelectItem>
<SelectItem value="rare"></SelectItem>
<SelectItem value="epic"></SelectItem>
<SelectItem value="legendary"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="print-quantity" className="text-right">
<span className="text-red-500">*</span>
</Label>
<Input
id="print-quantity"
type="number"
min={1}
value={printQuantity}
onChange={(e) => setPrintQuantity(Number.parseInt(e.target.value))}
placeholder="输入印刷数量"
className="border-gray-300 focus-visible:ring-pink-500"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-right">
<span className="text-red-500">*</span>
</Label>
<Textarea
id="description"
placeholder="输入服装描述"
className="min-h-[100px] border-gray-300 focus-visible:ring-pink-500"
required
/>
</div>
</div>
<DialogFooter className="flex items-center justify-between mt-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
</Button>
<Button
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? "创建中..." : "创建服装"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AddOutfitDialog onSave={handleAddOutfit} />
</DashboardHeader>
<div className="flex items-center justify-between space-y-2 mb-6">
@ -171,172 +178,141 @@ export default function OutfitsPage() {
type="search"
placeholder="搜索服装..."
className="w-[300px] pl-8 border-none bg-white shadow-md focus-visible:ring-pink-500"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setCurrentPage(1)
}}
/>
</div>
</div>
</div>
<Tabs defaultValue="all" className="space-y-4">
<TabsList className="bg-white p-1 shadow-md rounded-lg border">
<TabsTrigger
value="all"
className="data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white rounded-md transition-all duration-300"
>
</TabsTrigger>
<TabsTrigger
value="published"
className="data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white rounded-md transition-all duration-300"
>
</TabsTrigger>
<TabsTrigger
value="unpublished"
className="data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white rounded-md transition-all duration-300"
>
</TabsTrigger>
<TabsTrigger
value="limited"
className="data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white rounded-md transition-all duration-300"
>
</TabsTrigger>
</TabsList>
<TabsContent value="all" className="space-y-4">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
<CardHeader>
<CardTitle className="text-xl font-bold flex items-center">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
</span>
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600"></span>
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
</CardTitle>
<CardDescription></CardDescription>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
<span className="ml-2 text-muted-foreground">...</span>
</div>
) : (
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">OFT001</TableCell>
<TableCell className="font-medium text-pink-600"></TableCell>
<TableCell></TableCell>
<TableCell>2023-10-15</TableCell>
{outfits.map((outfit) => (
<TableRow key={outfit.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">{outfit.id}</TableCell>
<TableCell className="font-medium text-pink-600">{outfit.name}</TableCell>
<TableCell>{outfit.type}</TableCell>
<TableCell>{outfit.rarity}</TableCell>
<TableCell>{outfit.releaseDate || "-"}</TableCell>
<TableCell>
<Badge className="bg-green-500 hover:bg-green-600"></Badge>
</TableCell>
<TableCell className="font-medium">1,245</TableCell>
<TableCell className="font-medium">2,000</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
<Link href="/outfits/OFT001">
<Eye className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
<TableRow className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">OFT002</TableCell>
<TableCell className="font-medium text-blue-600"></TableCell>
<TableCell></TableCell>
<TableCell>2023-06-01</TableCell>
<TableCell>
<Badge className="bg-green-500 hover:bg-green-600"></Badge>
</TableCell>
<TableCell className="font-medium">876</TableCell>
<TableCell className="font-medium">1,500</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
<Link href="/outfits/OFT002">
<Eye className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
<TableRow className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">OFT003</TableCell>
<TableCell className="font-medium text-red-600"></TableCell>
<TableCell></TableCell>
<TableCell>2023-12-01</TableCell>
<TableCell>
<Badge className="bg-green-500 hover:bg-green-600"></Badge>
</TableCell>
<TableCell className="font-medium">1,032</TableCell>
<TableCell className="font-medium">2,000</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
<Link href="/outfits/OFT003">
<Eye className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
<TableRow className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">OFT004</TableCell>
<TableCell className="font-medium text-purple-600"></TableCell>
<TableCell></TableCell>
<TableCell>2024-01-15</TableCell>
<TableCell>
<Badge className="bg-green-500 hover:bg-green-600"></Badge>
</TableCell>
<TableCell className="font-medium">1,567</TableCell>
<TableCell className="font-medium">3,000</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
<Link href="/outfits/OFT004">
<Eye className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
<TableRow className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">OFT005</TableCell>
<TableCell className="font-medium text-teal-600"></TableCell>
<TableCell></TableCell>
<TableCell>-</TableCell>
<TableCell>
<Badge variant="outline" className="text-gray-500 border-gray-300">
<Badge
className={
outfit.status === "已发布"
? "bg-green-500 hover:bg-green-600"
: outfit.status === "已归档"
? "bg-orange-500 hover:bg-orange-600"
: "bg-gray-500 hover:bg-gray-600"
}
>
{outfit.status}
</Badge>
</TableCell>
<TableCell className="font-medium">0</TableCell>
<TableCell className="font-medium">1,000</TableCell>
<TableCell className="font-medium">{outfit.activatedCount}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
<Link href="/outfits/OFT005">
<Eye className="h-4 w-4" />
</Link>
</Button>
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
<Link href="/outfits/edit/OFT005">
<Edit className="h-4 w-4" />
<Link href={`/outfits/${outfit.id}`}>
<Eye className="h-4 w-4" />
<span className="sr-only"></span>
</Link>
</Button>
{outfit.status === "草稿" && (
<PublishConfirmationDialog
title="发布服装"
description="发布后该服装将可被用于卡牌生成"
itemName={outfit.name}
onPublish={() => handlePublishOutfit(outfit.id, outfit.name)}
/>
)}
{outfit.status === "已发布" && (
<Button
variant="ghost"
size="icon"
className="hover:bg-orange-50 hover:text-orange-600"
title="归档"
onClick={() => handleArchiveOutfit(outfit.id, outfit.name)}
>
<Archive className="h-4 w-4" />
</Button>
)}
{(outfit.status !== "已发布" || isSuperUser()) && (
<>
<Button
variant="ghost"
size="icon"
className="hover:bg-pink-50 hover:text-pink-600"
onClick={() => openEditDialog(outfit)}
>
<Edit className="h-4 w-4" />
</Button>
<DeleteConfirmationDialog
title="删除服装"
description="此操作将永久删除该服装及其所有相关数据。"
itemName={outfit.name}
onDelete={() => handleDeleteOutfit(outfit.id)}
/>
</>
)}
</TableCell>
</TableRow>
))}
{outfits.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
<CardFooter className="flex justify-between">
<div className="text-sm text-muted-foreground"> 1-5 24 </div>
<div className="text-sm text-muted-foreground">
{outfits.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
{Math.min(currentPage * itemsPerPage, totalItems)} {totalItems}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
>
</Button>
@ -344,145 +320,24 @@ export default function OutfitsPage() {
variant="outline"
size="sm"
className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages || totalPages === 0}
>
</Button>
</div>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="published" className="space-y-4">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
<CardHeader>
<CardTitle className="text-xl font-bold flex items-center">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
</span>
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">OFT001</TableCell>
<TableCell className="font-medium text-pink-600"></TableCell>
<TableCell></TableCell>
<TableCell>2023-10-15</TableCell>
<TableCell className="font-medium">1,245</TableCell>
<TableCell className="font-medium">2,000</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
<Link href="/outfits/OFT001">
<Eye className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
<TableRow className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">OFT002</TableCell>
<TableCell className="font-medium text-blue-600"></TableCell>
<TableCell></TableCell>
<TableCell>2023-06-01</TableCell>
<TableCell className="font-medium">876</TableCell>
<TableCell className="font-medium">1,500</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
<Link href="/outfits/OFT002">
<Eye className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="unpublished" className="space-y-4">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
<CardHeader>
<CardTitle className="text-xl font-bold flex items-center">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
</span>
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">OFT005</TableCell>
<TableCell className="font-medium text-teal-600"></TableCell>
<TableCell></TableCell>
<TableCell className="font-medium">1,000</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
<Link href="/outfits/OFT005">
<Eye className="h-4 w-4" />
</Link>
</Button>
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
<Link href="/outfits/edit/OFT005">
<Edit className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="limited" className="space-y-4">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
<CardHeader>
<CardTitle className="text-xl font-bold flex items-center">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
</span>
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center p-8">
<div className="flex flex-col items-center text-center">
<Sparkles className="h-12 w-12 text-blue-500 mb-4" />
<p className="text-lg font-medium text-gray-700"></p>
<p className="text-sm text-gray-500 mt-2"></p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{selectedOutfit && isEditDialogOpen && (
<AddOutfitDialog
mode="edit"
initialOutfit={selectedOutfit}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onSave={handleEditOutfit}
/>
)}
</DashboardShell>
)
}

View File

@ -130,6 +130,9 @@ const initialRoles: Role[] = [
"homeDecor.view": true,
"homeDecor.create": true,
"homeDecor.edit": true,
"food.view": true,
"food.create": true,
"food.edit": true,
},
},
{
@ -141,16 +144,6 @@ const initialRoles: Role[] = [
status: "自定义角色",
permissions: {
"dashboard.view": true,
"users.view": true,
"roles.view": true,
"ai.view": true,
"outfits.view": true,
"props.view": true,
"homeDecor.view": true,
"food.view": true,
"songs.view": true,
"settings.view": true,
"affinity.view": true,
},
},
]

View File

@ -1,152 +1,61 @@
"use client"
import { useState, useEffect, use } from "react"
import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ArrowLeft, Edit, AlertTriangle, FileText, Plus, Download } from "lucide-react"
import { ArrowLeft, Edit, AlertTriangle, FileText, Plus, Download, Loader2 } from "lucide-react"
import Link from "next/link"
import { AddPrintBatchDialog } from "@/components/props/add-print-batch-dialog"
import { ExportCardsDialog } from "@/components/props/export-cards-dialog"
import { isSuperUser } from "@/lib/api/auth"
import { getProp } from "@/lib/api/props"
import type { Prop } from "@/lib/api/types"
// Mock data for the prop details
const propData = {
PRP001: {
id: "PRP001",
name: "魔法麦克风",
type: "演出道具",
rarity: "稀有",
description: "洛天依的经典原创道具,可以增强歌声的魔力,让听众更加沉浸在音乐中。",
releaseDate: "2023-11-15",
status: "已发布",
activatedCount: 1245,
printedCount: 2000,
image: "/placeholder.svg?height=300&width=300",
batches: [
{
id: "B001",
date: "2023-09-01",
quantity: 1000,
startId: "PRP001-0001",
endId: "PRP001-1000",
status: "已激活",
activatedCount: 980,
},
{
id: "B002",
date: "2023-12-15",
quantity: 1000,
startId: "PRP001-1001",
endId: "PRP001-2000",
status: "已激活",
activatedCount: 265,
},
],
},
PRP002: {
id: "PRP002",
name: "星光魔杖",
type: "互动道具",
rarity: "史诗",
description: "挥舞魔杖可以创造出美丽的星光效果,增加互动时的好感度。",
releaseDate: "2023-12-01",
status: "已发布",
activatedCount: 876,
printedCount: 1500,
image: "/placeholder.svg?height=300&width=300",
batches: [
{
id: "B003",
date: "2023-05-10",
quantity: 1500,
startId: "PRP002-0001",
endId: "PRP002-1500",
status: "已激活",
activatedCount: 876,
},
],
},
PRP003: {
id: "PRP003",
name: "音乐盒",
type: "收藏品",
rarity: "传说",
description: "精美的音乐盒,打开后会播放洛天依的经典歌曲,是珍贵的收藏品。",
releaseDate: "2024-01-10",
status: "已发布",
activatedCount: 532,
printedCount: 1000,
image: "/placeholder.svg?height=300&width=300",
batches: [
{
id: "B004",
date: "2023-11-15",
quantity: 1000,
startId: "PRP003-0001",
endId: "PRP003-1000",
status: "已激活",
activatedCount: 532,
},
],
},
PRP004: {
id: "PRP004",
name: "虚拟相机",
type: "互动道具",
rarity: "稀有",
description: "可以捕捉洛天依的精彩瞬间,保存为虚拟照片。",
releaseDate: "2024-02-05",
status: "已发布",
activatedCount: 967,
printedCount: 2000,
image: "/placeholder.svg?height=300&width=300",
batches: [
{
id: "B005",
date: "2024-01-05",
quantity: 2000,
startId: "PRP004-0001",
endId: "PRP004-2000",
status: "已激活",
activatedCount: 967,
},
],
},
PRP005: {
id: "PRP005",
name: "节日礼盒",
type: "限定道具",
rarity: "史诗",
description: "节日限定礼盒,内含多种惊喜道具和装饰品。",
releaseDate: "",
status: "未发布",
activatedCount: 0,
printedCount: 1000,
image: "/placeholder.svg?height=300&width=300",
batches: [
{
id: "B007",
date: "2024-03-20",
quantity: 1000,
startId: "PRP005-0001",
endId: "PRP005-1000",
status: "未激活",
activatedCount: 0,
},
],
},
export default function PropDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [prop, setProp] = useState<Prop | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchProp = async () => {
try {
setLoading(true)
setError(null)
const data = await getProp(id)
setProp(data)
} catch (err) {
console.error("获取道具详情失败:", err)
setError(`找不到ID为 ${id} 的道具`)
} finally {
setLoading(false)
}
}
fetchProp()
}, [id])
if (loading) {
return (
<DashboardShell>
<div className="flex items-center justify-center h-[60vh]">
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
<span className="ml-2 text-muted-foreground">...</span>
</div>
</DashboardShell>
)
}
export default function PropDetailPage({ params }: { params: { id: string } }) {
const prop = propData[params.id as keyof typeof propData]
if (!prop) {
if (error || !prop) {
return (
<DashboardShell>
<div className="flex flex-col items-center justify-center h-[60vh]">
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-gray-500 mb-6">ID为 {params.id} </p>
<p className="text-gray-500 mb-6">{error || `找不到ID为 ${id} 的道具`}</p>
<Button asChild>
<Link href="/props">
<ArrowLeft className="mr-2 h-4 w-4" />
@ -159,7 +68,9 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
}
const isPublished = prop.status === "已发布"
const activationRate = prop.printedCount > 0 ? Math.round((prop.activatedCount / prop.printedCount) * 100) : 0
const printedCount = prop.batchesCount || 0
const activatedCount = prop.activeCardsCount || 0
const activationRate = printedCount > 0 ? Math.round((activatedCount / printedCount) * 100) : 0
return (
<DashboardShell>
@ -174,12 +85,12 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
</Button>
<DashboardHeader heading={prop.name} text={`道具ID: ${prop.id}`}>
<div className="flex space-x-2 ml-auto">
{!isPublished && (
{(!isPublished || isSuperUser()) && (
<Button
asChild
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg"
>
<Link href={`/props/edit/${params.id}`}>
<Link href={`/props/edit/${id}`}>
<Edit className="mr-2 h-4 w-4" />
</Link>
@ -205,9 +116,18 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
</CardHeader>
<CardContent className="flex justify-center">
<div className="relative w-full aspect-square max-w-[300px] rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
<img src={prop.image || "/placeholder.svg"} alt={prop.name} className="object-cover" />
{prop.imageUrl && !prop.imageUrl.includes("placeholder") ? (
<img src={prop.imageUrl} alt={prop.name} className="object-cover w-full h-full" />
) : (
<div className="flex flex-col items-center justify-center text-gray-400">
<svg className="h-16 w-16 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
<span className="text-sm"></span>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3">
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{prop.status}</Badge>
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{prop.status || "未发布"}</Badge>
</div>
</div>
</CardContent>
@ -221,23 +141,23 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{prop.type}</p>
<p className="font-medium">{prop.category || "-"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{prop.rarity}</p>
<p className="font-medium">{prop.rarity || "-"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{prop.releaseDate || "尚未发布"}</p>
<p className="font-medium">{prop.publishedAt || "尚未发布"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{prop.activatedCount}</p>
<p className="font-medium">{activatedCount}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{prop.printedCount}</p>
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{prop.createdAt || "-"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
@ -247,13 +167,17 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
<div className="mt-6">
<p className="text-sm font-medium text-gray-500 mb-2"></p>
<p className="text-gray-700">{prop.description}</p>
<p className="text-gray-700">{prop.description || "暂无描述"}</p>
</div>
{isPublished && (
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start">
<AlertTriangle className="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700"></p>
<p className="text-sm text-amber-700">
{isSuperUser()
? "该道具已发布,您以超级管理员身份仍可编辑和删除。请谨慎操作。"
: "该道具已发布,基本属性不可修改。您仍可以增加印刷数量。"}
</p>
</div>
)}
</CardContent>
@ -278,41 +202,16 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-right text-sm font-medium text-gray-500"></th>
</tr>
</thead>
<tbody>
{prop.batches.map((batch) => (
<tr key={batch.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 text-sm font-medium">{batch.id}</td>
<td className="py-3 px-4 text-sm">{batch.date}</td>
<td className="py-3 px-4 text-sm">{batch.quantity}</td>
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.startId}</td>
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.endId}</td>
<td className="py-3 px-4 text-sm">
<Badge className={`${batch.status === "已激活" ? "bg-green-500" : "bg-gray-500"}`}>
{batch.status}
</Badge>
</td>
<td className="py-3 px-4 text-sm">{batch.activatedCount}</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end space-x-2">
<Button variant="ghost" size="sm" className="h-8 hover:bg-blue-50 hover:text-blue-600">
<FileText className="h-4 w-4 mr-1" />
</Button>
<Button variant="ghost" size="sm" className="h-8 hover:bg-green-50 hover:text-green-600">
<Download className="h-4 w-4 mr-1" />
</Button>
</div>
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
</td>
</tr>
))}
</tbody>
</table>
</div>

View File

@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect, useCallback } from "react"
import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button"
@ -8,96 +8,91 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Search, Edit, Eye } from "lucide-react"
import { Search, Edit, Eye, Loader2, Archive } from "lucide-react"
import { AddPropDialog } from "@/components/props/add-prop-dialog"
import type { Prop } from "@/components/props/prop-detail-dialog"
import type { Prop as ComponentProp } from "@/components/props/prop-detail-dialog"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
import { useToast } from "@/components/ui/use-toast"
import { isSuperUser } from "@/lib/api/auth"
import { getProps, deleteProp, publishProp, archiveProp } from "@/lib/api/props"
import type { Prop } from "@/lib/api/types"
import Link from "next/link"
// 初始道具数据
const initialProps: Prop[] = [
{
id: "PRP001",
name: "魔法麦克风",
type: "演出道具",
rarity: "稀有",
description: "洛天依的经典原创道具,可以增强歌声的魔力,让听众更加沉浸在音乐中。",
releaseDate: "2023-11-15",
status: "已发布",
activatedCount: 1245,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "PRP002",
name: "星光魔杖",
type: "互动道具",
rarity: "史诗",
description: "挥舞魔杖可以创造出美丽的星光效果,增加互动时的好感度。",
releaseDate: "2023-12-01",
status: "已发布",
activatedCount: 876,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "PRP003",
name: "音乐盒",
type: "收藏品",
rarity: "传说",
description: "精美的音乐盒,打开后会播放洛天依的经典歌曲,是珍贵的收藏品。",
releaseDate: "2024-01-10",
status: "已发布",
activatedCount: 532,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "PRP004",
name: "虚拟相机",
type: "互动道具",
rarity: "稀有",
description: "可以捕捉洛天依的精彩瞬间,保存为虚拟照片。",
releaseDate: "2024-02-05",
status: "已发布",
activatedCount: 967,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "PRP005",
name: "节日礼盒",
type: "限定道具",
rarity: "史诗",
description: "节日限定礼盒,内含多种惊喜道具和装饰品。",
releaseDate: "",
status: "未发布",
activatedCount: 0,
image: "/placeholder.svg?height=300&width=300",
},
]
// 格式化日期时间
function formatDate(dateStr: string): string {
if (!dateStr) return ""
try {
return new Date(dateStr).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
} catch {
return dateStr
}
}
// 将 API Prop 转换为组件显示用的 Prop
function toDisplayProp(prop: Prop): ComponentProp {
return {
id: prop.id,
name: prop.name,
type: prop.category || "",
rarity: prop.rarity || "",
description: prop.description || "",
releaseDate: formatDate(prop.publishedAt || prop.createdAt || ""),
status: prop.status || "未发布",
activatedCount: prop.activeCardsCount || 0,
image: prop.imageUrl || "/placeholder.svg?height=300&width=300",
}
}
export default function PropsPage() {
const { toast } = useToast()
const [props, setProps] = useState<Prop[]>(initialProps)
const [props, setProps] = useState<ComponentProp[]>([])
const [searchTerm, setSearchTerm] = useState("")
const [currentPage, setCurrentPage] = useState(1)
const [selectedProp, setSelectedProp] = useState<Prop | null>(null)
const [totalItems, setTotalItems] = useState(0)
const [selectedProp, setSelectedProp] = useState<ComponentProp | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [loading, setLoading] = useState(true)
const itemsPerPage = 5
const itemsPerPage = 10
// 过滤和分页
const filteredProps = props.filter(
(prop) =>
prop.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
prop.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
prop.type.toLowerCase().includes(searchTerm.toLowerCase()),
)
// 从后端获取道具列表
const fetchProps = useCallback(async () => {
try {
setLoading(true)
const response = await getProps({
page: currentPage,
pageSize: itemsPerPage,
search: searchTerm || undefined,
})
setProps(response.items.map(toDisplayProp))
setTotalItems(response.total)
} catch (error) {
console.error("获取道具列表失败:", error)
toast({
title: "获取失败",
description: "无法获取道具列表,请稍后重试",
variant: "destructive",
})
} finally {
setLoading(false)
}
}, [currentPage, searchTerm, toast])
const totalPages = Math.ceil(filteredProps.length / itemsPerPage)
const paginatedProps = filteredProps.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
useEffect(() => {
fetchProps()
}, [fetchProps])
const totalPages = Math.ceil(totalItems / itemsPerPage)
// 处理添加道具
const handleAddProp = (newProp: Prop) => {
setProps((prevProps) => [...prevProps, newProp])
const handleAddProp = (newProp: ComponentProp) => {
// 添加后刷新列表
fetchProps()
toast({
title: "添加成功",
description: `道具 ${newProp.name} 已成功添加`,
@ -105,8 +100,9 @@ export default function PropsPage() {
}
// 处理编辑道具
const handleEditProp = (updatedProp: Prop) => {
setProps((prevProps) => prevProps.map((prop) => (prop.id === updatedProp.id ? updatedProp : prop)))
const handleEditProp = (updatedProp: ComponentProp) => {
// 编辑后刷新列表
fetchProps()
setSelectedProp(null)
setIsEditDialogOpen(false)
toast({
@ -115,21 +111,67 @@ export default function PropsPage() {
})
}
// 处理删除道具
// 处理删除道具 - 调用真实的后端 API
const handleDeleteProp = async (propId: string) => {
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 1000))
setProps((prevProps) => prevProps.filter((prop) => prop.id !== propId))
try {
await deleteProp(propId)
// 删除后刷新列表
await fetchProps()
toast({
title: "删除成功",
description: "道具已成功删除",
variant: "destructive",
})
} catch (error) {
console.error("删除道具失败:", error)
toast({
title: "删除失败",
description: "无法删除道具,请稍后重试",
variant: "destructive",
})
}
}
// 发布道具
const handlePublishProp = async (propId: string, propName: string) => {
try {
await publishProp(propId)
await fetchProps()
toast({
title: "发布成功",
description: `道具 "${propName}" 已成功发布`,
})
} catch (error) {
console.error("发布道具失败:", error)
toast({
title: "发布失败",
description: "无法发布道具,请稍后重试",
variant: "destructive",
})
}
}
// 归档道具
const handleArchiveProp = async (propId: string, propName: string) => {
try {
await archiveProp(propId)
await fetchProps()
toast({
title: "归档成功",
description: `道具 "${propName}" 已归档`,
})
} catch (error) {
console.error("归档道具失败:", error)
toast({
title: "归档失败",
description: "无法归档道具,请稍后重试",
variant: "destructive",
})
}
}
// 打开编辑对话框
const openEditDialog = (prop: Prop) => {
const openEditDialog = (prop: ComponentProp) => {
setSelectedProp(prop)
setIsEditDialogOpen(true)
}
@ -151,7 +193,7 @@ export default function PropsPage() {
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setCurrentPage(1) // 重置到第一页
setCurrentPage(1)
}}
/>
</div>
@ -167,6 +209,12 @@ export default function PropsPage() {
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
<span className="ml-2 text-muted-foreground">...</span>
</div>
) : (
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
@ -181,7 +229,7 @@ export default function PropsPage() {
</TableRow>
</TableHeader>
<TableBody>
{paginatedProps.map((prop) => (
{props.map((prop) => (
<TableRow key={prop.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">{prop.id}</TableCell>
<TableCell className="font-medium text-pink-600">{prop.name}</TableCell>
@ -191,7 +239,11 @@ export default function PropsPage() {
<TableCell>
<Badge
className={
prop.status === "已发布" ? "bg-green-500 hover:bg-green-600" : "bg-gray-500 hover:bg-gray-600"
prop.status === "已发布"
? "bg-green-500 hover:bg-green-600"
: prop.status === "已归档"
? "bg-orange-500 hover:bg-orange-600"
: "bg-gray-500 hover:bg-gray-600"
}
>
{prop.status}
@ -199,7 +251,6 @@ export default function PropsPage() {
</TableCell>
<TableCell className="font-medium">{prop.activatedCount}</TableCell>
<TableCell className="text-right">
{/* 将详情对话框替换为链接按钮 */}
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
<Link href={`/props/${prop.id}`}>
<Eye className="h-4 w-4" />
@ -207,7 +258,30 @@ export default function PropsPage() {
</Link>
</Button>
{prop.status !== "已发布" && (
{/* 草稿状态:显示发布按钮 */}
{prop.status === "草稿" && (
<PublishConfirmationDialog
title="发布道具"
description="发布后该道具将可被用于卡牌生成"
itemName={prop.name}
onPublish={() => handlePublishProp(prop.id, prop.name)}
/>
)}
{/* 已发布状态:显示归档按钮 */}
{prop.status === "已发布" && (
<Button
variant="ghost"
size="icon"
className="hover:bg-orange-50 hover:text-orange-600"
title="归档"
onClick={() => handleArchiveProp(prop.id, prop.name)}
>
<Archive className="h-4 w-4" />
</Button>
)}
{(prop.status !== "已发布" || isSuperUser()) && (
<>
<Button
variant="ghost"
@ -230,7 +304,7 @@ export default function PropsPage() {
</TableRow>
))}
{paginatedProps.length === 0 && (
{props.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
@ -239,11 +313,12 @@ export default function PropsPage() {
)}
</TableBody>
</Table>
)}
</CardContent>
<CardFooter className="flex justify-between">
<div className="text-sm text-muted-foreground">
{paginatedProps.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
{Math.min(currentPage * itemsPerPage, filteredProps.length)} {filteredProps.length}
{props.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
{Math.min(currentPage * itemsPerPage, totalItems)} {totalItems}
</div>
<div className="flex items-center space-x-2">
<Button

View File

@ -18,7 +18,8 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Loader2, Upload, Video } from "lucide-react"
import { Loader2, Trash2 } from "lucide-react"
import { FileUpload } from "@/components/ui/file-upload"
interface AddDanceDialogProps {
open: boolean
@ -42,6 +43,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
tags: [],
motionFile: "",
videoUrl: "",
coverUrl: "",
})
// 预览ID
@ -65,6 +67,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
tags: editDance.tags || [],
motionFile: editDance.motionFile || "",
videoUrl: editDance.videoUrl || "",
coverUrl: editDance.coverUrl || "",
})
} else {
// 重置表单
@ -78,6 +81,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
tags: [],
motionFile: "",
videoUrl: "",
coverUrl: "",
})
// 生成新的预览ID
setPreviewId(
@ -137,7 +141,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
tags: formData.tags || [],
motionFile: formData.motionFile || "",
videoUrl: formData.videoUrl || "",
coverUrl: editDance?.coverUrl || "/placeholder.svg?height=300&width=400",
coverUrl: formData.coverUrl || editDance?.coverUrl || "/placeholder.svg?height=300&width=400",
createdAt: editDance?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
@ -298,20 +302,82 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
<div className="space-y-2">
<Label className="text-right"></Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-purple-500 transition-colors cursor-pointer">
<Video className="h-8 w-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-500"></p>
<p className="text-xs text-gray-400 mt-1"> PNG, JPG, JPEG 5MB</p>
{formData.coverUrl && !formData.coverUrl.includes('placeholder') ? (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center space-x-3">
<img
src={formData.coverUrl}
alt="舞蹈封面"
className="h-16 w-16 object-cover rounded border"
/>
<span className="text-sm text-gray-700"></span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setFormData((prev) => ({ ...prev, coverUrl: "" }))}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={isSubmitting}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<FileUpload
imageOnly={true}
multiple={false}
maxFiles={1}
maxSize={5 * 1024 * 1024}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
placeholder="点击或拖拽上传舞蹈封面"
disabled={isSubmitting}
onUploadSuccess={(files) => {
if (files.length > 0) {
setFormData((prev) => ({ ...prev, coverUrl: files[0].url }))
}
}}
/>
)}
</div>
<div className="space-y-2">
<Label className="text-right"></Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-purple-500 transition-colors cursor-pointer">
<Upload className="h-8 w-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-500"></p>
<p className="text-xs text-gray-400 mt-1"> FBX, BVH 20MB</p>
{formData.motionFile ? (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center space-x-3">
<div className="h-10 w-10 bg-purple-100 rounded border flex items-center justify-center">
<span className="text-xs font-medium text-purple-600">FBX</span>
</div>
<span className="text-sm text-gray-700"></span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setFormData((prev) => ({ ...prev, motionFile: "" }))}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={isSubmitting}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<FileUpload
imageOnly={false}
multiple={false}
maxFiles={1}
maxSize={20 * 1024 * 1024}
accept={{ 'application/octet-stream': ['.fbx', '.bvh'] }}
placeholder="点击或拖拽上传动作文件"
disabled={isSubmitting}
onUploadSuccess={(files) => {
if (files.length > 0) {
setFormData((prev) => ({ ...prev, motionFile: files[0].url }))
}
}}
/>
)}
</div>
{!editDance && (

View File

@ -1,7 +1,7 @@
import type React from "react"
interface DashboardHeaderProps {
heading: string
text?: string
heading: React.ReactNode
text?: React.ReactNode
children?: React.ReactNode
}
@ -12,7 +12,7 @@ export function DashboardHeader({ heading, text, children }: DashboardHeaderProp
<h1 className="font-heading text-3xl md:text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 via-purple-900 to-pink-700">
{heading}
</h1>
{text && <p className="text-lg text-muted-foreground">{text}</p>}
{text && <div className="text-lg text-muted-foreground">{text}</div>}
</div>
{children}
</div>

View File

@ -1,15 +1,48 @@
"use client"
import type React from "react"
import { useEffect, useState } from "react"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { Sidebar } from "@/components/sidebar"
import { hasPathPermission, getUserRole } from "@/lib/permissions"
import { Button } from "@/components/ui/button"
import { ShieldX } from "lucide-react"
import Link from "next/link"
interface DashboardShellProps extends React.HTMLAttributes<HTMLDivElement> {}
export function DashboardShell({ children, className, ...props }: DashboardShellProps) {
const pathname = usePathname()
const [allowed, setAllowed] = useState<boolean | null>(null)
useEffect(() => {
setAllowed(hasPathPermission(pathname))
}, [pathname])
const showAccessDenied = allowed === false
return (
<div className="grid min-h-screen w-full md:grid-cols-[280px_1fr] bg-gradient-to-br from-gray-50 to-white">
<Sidebar />
<main className={cn("flex flex-col relative overflow-hidden", className)} {...props}>
<div className="flex-1 space-y-6 p-8 pt-6">{children}</div>
<div className="flex-1 space-y-6 p-8 pt-6">
{showAccessDenied ? (
<div className="flex flex-col items-center justify-center h-[60vh]">
<ShieldX className="h-16 w-16 text-red-400 mb-4" />
<h1 className="text-2xl font-bold mb-2">访</h1>
<p className="text-gray-500 mb-2">
{getUserRole()}访
</p>
<p className="text-gray-400 text-sm mb-6"></p>
<Button asChild>
<Link href="/"></Link>
</Button>
</div>
) : (
children
)}
</div>
</main>
</div>
)

View File

@ -73,7 +73,7 @@ export function AddFoodDialog({
setTasteTags(initialFood.taste_tags || "")
setNutritionalValue(initialFood.nutritional_value || "")
setEffectDescription(initialFood.effect_description || "")
setIsLimited(initialFood.food_type?.includes("限定") || false)
setIsLimited(initialFood.is_limited || false)
setPreviewId(initialFood.id)
setImageUrl(initialFood.image)
setAnimationUrl(initialFood.animation_file)
@ -151,7 +151,7 @@ export function AddFoodDialog({
// 构建食物数据
const foodData: Partial<Food> = {
name,
food_type: isLimited ? `限定${foodType}` : foodType,
food_type: foodType,
rarity,
description,
image: imageUrl,
@ -161,7 +161,8 @@ export function AddFoodDialog({
taste_tags: tasteTags,
nutritional_value: nutritionalValue,
effect_description: effectDescription,
status: "published",
is_limited: isLimited,
...(mode === "create" ? { status: "draft" } : {}),
}
let result: Food
@ -258,11 +259,16 @@ export function AddFoodDialog({
</SelectTrigger>
<SelectContent>
<SelectItem value="fruit"></SelectItem>
<SelectItem value="dessert"></SelectItem>
<SelectItem value="drink"></SelectItem>
<SelectItem value="vegetable"></SelectItem>
<SelectItem value="meat"></SelectItem>
<SelectItem value="seafood"></SelectItem>
<SelectItem value="dairy"></SelectItem>
<SelectItem value="grain"></SelectItem>
<SelectItem value="snack"></SelectItem>
<SelectItem value="main_dish"></SelectItem>
<SelectItem value="special"></SelectItem>
<SelectItem value="drink"></SelectItem>
<SelectItem value="dessert"></SelectItem>
<SelectItem value="spice"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>

View File

@ -18,8 +18,10 @@ export type Food = {
id: string
name: string
food_type: string
food_type_display?: string
description: string
rarity: string
rarity_display?: string
image?: string
animation_file?: string
sound_effect?: string
@ -28,6 +30,7 @@ export type Food = {
nutritional_value?: string
effect_description?: string
boost_attributes?: Record<string, number>
is_limited?: boolean
status: string
created_at?: string
updated_at?: string

View File

@ -6,7 +6,6 @@ import { FileUpload } from '@/components/ui/file-upload'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Image, Play, Music, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { UploadResponse } from '@/lib/api/upload'
interface FoodMediaUploadProps {
/** 当前图片URL */
@ -39,18 +38,6 @@ export function FoodMediaUpload({
}: FoodMediaUploadProps) {
const [activeTab, setActiveTab] = useState('image')
// 准备默认文件列表
const getDefaultFiles = (url?: string, type: string = 'image'): UploadResponse[] => {
if (!url) return []
return [{
url,
filename: `当前${type === 'image' ? '图片' : type === 'animation' ? '动画' : '音频'}`,
size: 0,
mimeType: type === 'image' ? 'image/jpeg' : type === 'animation' ? 'video/mp4' : 'audio/mp3'
}]
}
return (
<div className="space-y-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
@ -82,30 +69,13 @@ export function FoodMediaUpload({
</CardDescription>
</CardHeader>
<CardContent>
<FileUpload
imageOnly={true}
multiple={false}
maxFiles={1}
maxSize={5 * 1024 * 1024} // 5MB
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
placeholder="选择或拖拽食物图片"
defaultFiles={getDefaultFiles(imageUrl, 'image')}
disabled={disabled}
onUploadSuccess={(files) => {
if (files.length > 0) {
onImageUpload?.(files[0].url)
}
}}
onRemove={() => onRemove?.('image')}
/>
{imageUrl && (
<div className="mt-4 flex items-center justify-between p-3 bg-gray-50 rounded-lg">
{imageUrl ? (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center space-x-3">
<img
src={imageUrl}
alt="食物图片预览"
className="h-12 w-12 object-cover rounded border"
className="h-16 w-16 object-cover rounded border"
/>
<span className="text-sm text-gray-700"></span>
</div>
@ -115,10 +85,27 @@ export function FoodMediaUpload({
size="sm"
onClick={() => onRemove?.('image')}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<FileUpload
imageOnly={true}
multiple={false}
maxFiles={1}
maxSize={5 * 1024 * 1024}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
placeholder="选择或拖拽食物图片"
disabled={disabled}
onUploadSuccess={(files) => {
if (files.length > 0) {
onImageUpload?.(files[0].url)
}
}}
onRemove={() => onRemove?.('image')}
/>
)}
</CardContent>
</Card>
@ -137,48 +124,26 @@ export function FoodMediaUpload({
</CardDescription>
</CardHeader>
<CardContent>
<FileUpload
imageOnly={false}
multiple={false}
maxFiles={1}
maxSize={50 * 1024 * 1024} // 50MB
accept={{
'video/*': ['.mp4', '.avi', '.mov', '.wmv', '.flv'],
'image/gif': ['.gif'],
'application/json': ['.json'],
'application/x-lottie': ['.lottie']
}}
placeholder="选择或拖拽动画文件"
defaultFiles={getDefaultFiles(animationUrl, 'animation')}
disabled={disabled}
onUploadSuccess={(files) => {
if (files.length > 0) {
onAnimationUpload?.(files[0].url)
}
}}
onRemove={() => onRemove?.('animation')}
/>
{animationUrl && (
<div className="mt-4 flex items-center justify-between p-3 bg-gray-50 rounded-lg">
{animationUrl ? (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center space-x-3">
{animationUrl.endsWith('.gif') ? (
<img
src={animationUrl}
alt="动画预览"
className="h-12 w-12 object-cover rounded border"
className="h-16 w-16 object-cover rounded border"
/>
) : animationUrl.match(/\.(mp4|avi|mov|wmv|flv)$/i) ? (
<video
src={animationUrl}
className="h-12 w-12 object-cover rounded border"
className="h-16 w-16 object-cover rounded border"
muted
loop
autoPlay
/>
) : (
<div className="h-12 w-12 bg-gray-200 rounded border flex items-center justify-center">
<Play className="h-4 w-4 text-gray-500" />
<div className="h-16 w-16 bg-gray-200 rounded border flex items-center justify-center">
<Play className="h-6 w-6 text-gray-500" />
</div>
)}
<span className="text-sm text-gray-700"></span>
@ -189,10 +154,32 @@ export function FoodMediaUpload({
size="sm"
onClick={() => onRemove?.('animation')}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<FileUpload
imageOnly={false}
multiple={false}
maxFiles={1}
maxSize={50 * 1024 * 1024}
accept={{
'video/*': ['.mp4', '.avi', '.mov', '.wmv', '.flv'],
'image/gif': ['.gif'],
'application/json': ['.json'],
'application/x-lottie': ['.lottie']
}}
placeholder="选择或拖拽动画文件"
disabled={disabled}
onUploadSuccess={(files) => {
if (files.length > 0) {
onAnimationUpload?.(files[0].url)
}
}}
onRemove={() => onRemove?.('animation')}
/>
)}
</CardContent>
</Card>
@ -211,30 +198,11 @@ export function FoodMediaUpload({
</CardDescription>
</CardHeader>
<CardContent>
<FileUpload
imageOnly={false}
multiple={false}
maxFiles={1}
maxSize={20 * 1024 * 1024} // 20MB
accept={{
'audio/*': ['.mp3', '.wav', '.ogg', '.aac', '.flac', '.wma', '.m4a']
}}
placeholder="选择或拖拽音频文件"
defaultFiles={getDefaultFiles(audioUrl, 'audio')}
disabled={disabled}
onUploadSuccess={(files) => {
if (files.length > 0) {
onAudioUpload?.(files[0].url)
}
}}
onRemove={() => onRemove?.('audio')}
/>
{audioUrl && (
<div className="mt-4 flex items-center justify-between p-3 bg-gray-50 rounded-lg">
{audioUrl ? (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center space-x-3">
<div className="h-12 w-12 bg-blue-100 rounded border flex items-center justify-center">
<Music className="h-4 w-4 text-blue-500" />
<div className="h-16 w-16 bg-blue-100 rounded border flex items-center justify-center">
<Music className="h-6 w-6 text-blue-500" />
</div>
<div className="flex-1">
<span className="text-sm text-gray-700 block"></span>
@ -250,10 +218,29 @@ export function FoodMediaUpload({
size="sm"
onClick={() => onRemove?.('audio')}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<FileUpload
imageOnly={false}
multiple={false}
maxFiles={1}
maxSize={20 * 1024 * 1024}
accept={{
'audio/*': ['.mp3', '.wav', '.ogg', '.aac', '.flac', '.wma', '.m4a']
}}
placeholder="选择或拖拽音频文件"
disabled={disabled}
onUploadSuccess={(files) => {
if (files.length > 0) {
onAudioUpload?.(files[0].url)
}
}}
onRemove={() => onRemove?.('audio')}
/>
)}
</CardContent>
</Card>

View File

@ -15,9 +15,25 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Plus, Upload, AlertTriangle, Loader2 } from "lucide-react"
import { Plus, AlertTriangle, Loader2, Trash2 } from "lucide-react"
import { Switch } from "@/components/ui/switch"
import { FileUpload } from "@/components/ui/file-upload"
import type { HomeDecor } from "./home-decor-detail-dialog"
import { createHomeDecor, updateHomeDecor } from "@/lib/api/home-decor"
// 判断是否为有效图片URL非placeholder
function isRealImageUrl(url?: string): boolean {
if (!url) return false
return !url.includes("placeholder")
}
// 中文稀有度 -> 后端 rarity key
const RARITY_MAP: Record<string, string> = {
"普通": "common",
"稀有": "rare",
"史诗": "epic",
"传说": "legendary",
}
type AddHomeDecorDialogProps = {
mode?: "create" | "edit"
@ -43,6 +59,7 @@ export function AddHomeDecorDialog({
const [rarity, setRarity] = useState("")
const [description, setDescription] = useState("")
const [isLimited, setIsLimited] = useState(false)
const [imageUrl, setImageUrl] = useState<string | undefined>()
const [previewId, setPreviewId] = useState(
"DEC" +
Math.floor(Math.random() * 1000)
@ -59,6 +76,7 @@ export function AddHomeDecorDialog({
setDescription(initialDecor.description)
setIsLimited(initialDecor.type.includes("限定"))
setPreviewId(initialDecor.id)
setImageUrl(isRealImageUrl(initialDecor.image) ? initialDecor.image : undefined)
}
}, [mode, initialDecor])
@ -90,6 +108,7 @@ export function AddHomeDecorDialog({
setRarity("")
setDescription("")
setIsLimited(false)
setImageUrl(undefined)
setPreviewId(
"DEC" +
Math.floor(Math.random() * 1000)
@ -108,27 +127,59 @@ export function AddHomeDecorDialog({
setIsSubmitting(true)
try {
// 构建装饰对象
const decor: HomeDecor = {
id: initialDecor?.id || previewId,
const actualType = isLimited ? `限定${decorType}` : decorType
if (mode === "create") {
// 调用后端创建 API
const created = await createHomeDecor({
name,
type: isLimited ? `限定${decorType}` : decorType,
rarity,
description,
releaseDate: initialDecor?.releaseDate || "",
status: initialDecor?.status || "未发布",
activatedCount: initialDecor?.activatedCount || 0,
// 不使用外部图片URL避免加载错误
image: undefined,
category: actualType,
rarityValue: RARITY_MAP[rarity] || rarity,
imageUrl,
})
// 构建组件格式的 HomeDecor 用于回调
const decor: HomeDecor = {
id: created.id,
name: created.name,
type: created.category || actualType,
rarity: created.rarity || rarity,
description: created.description || description,
releaseDate: created.createdAt || "",
status: created.status || "未发布",
activatedCount: created.activeCardsCount || 0,
image: created.imageUrl || "/placeholder.svg?height=300&width=300",
}
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 1500))
// 调用保存回调
if (onSave) {
onSave(decor)
}
} else {
// 调用后端更新 API
const updated = await updateHomeDecor(initialDecor!.id, {
name,
description,
category: actualType,
imageUrl,
})
const decor: HomeDecor = {
id: updated.id,
name: updated.name,
type: updated.category || actualType,
rarity: updated.rarity || rarity,
description: updated.description || description,
releaseDate: initialDecor?.releaseDate || "",
status: updated.status || initialDecor?.status || "未发布",
activatedCount: updated.activeCardsCount || initialDecor?.activatedCount || 0,
image: updated.imageUrl || initialDecor?.image || "/placeholder.svg?height=300&width=300",
}
if (onSave) {
onSave(decor)
}
}
// 关闭对话框
handleOpenChange(false)
@ -247,11 +298,43 @@ export function AddHomeDecorDialog({
<Label className="text-right">
{mode === "create" && <span className="text-red-500">*</span>}
</Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
<Upload className="h-8 w-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-500"></p>
<p className="text-xs text-gray-400 mt-1"> PNG, JPG, JPEG 5MB</p>
{imageUrl ? (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center space-x-3">
<img
src={imageUrl}
alt="装饰图片"
className="h-16 w-16 object-cover rounded border"
/>
<span className="text-sm text-gray-700"></span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setImageUrl(undefined)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={isSubmitting}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<FileUpload
imageOnly={true}
multiple={false}
maxFiles={1}
maxSize={5 * 1024 * 1024}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
placeholder="点击或拖拽上传装饰图片"
disabled={isSubmitting}
onUploadSuccess={(files) => {
if (files.length > 0) {
setImageUrl(files[0].url)
}
}}
/>
)}
</div>
<div className="flex items-center space-x-2 pt-2">

View File

@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
@ -15,19 +15,59 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Plus, Upload, AlertTriangle, Loader2 } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Plus, AlertTriangle, Loader2, Trash2 } from "lucide-react"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { FileUpload } from "@/components/ui/file-upload"
import { createOutfit, updateOutfit } from "@/lib/api/outfits"
export function AddOutfitDialog() {
function isRealImageUrl(url?: string): boolean {
if (!url) return false
return !url.includes("placeholder")
}
const RARITY_MAP: Record<string, string> = {
"普通": "common",
"稀有": "rare",
"史诗": "epic",
"传说": "legendary",
}
export type DisplayOutfit = {
id: string
name: string
type: string
rarity: string
description: string
releaseDate: string
status: string
activatedCount: number
image?: string
}
type AddOutfitDialogProps = {
mode?: "create" | "edit"
initialOutfit?: DisplayOutfit
open?: boolean
onOpenChange?: (open: boolean) => void
onSave?: (outfit: DisplayOutfit) => void
}
export function AddOutfitDialog({
mode = "create",
initialOutfit,
open: controlledOpen,
onOpenChange: setControlledOpen,
onSave,
}: AddOutfitDialogProps) {
const [open, setOpen] = useState(false)
const [step, setStep] = useState(1)
const [isSubmitting, setIsSubmitting] = useState(false)
const [name, setName] = useState("")
const [outfitType, setOutfitType] = useState("")
const [rarity, setRarity] = useState("")
const [printQuantity, setPrintQuantity] = useState(1000)
const [description, setDescription] = useState("")
const [isLimited, setIsLimited] = useState(false)
const [imageUrl, setImageUrl] = useState<string | undefined>()
const [previewId, setPreviewId] = useState(
"OFT" +
Math.floor(Math.random() * 1000)
@ -35,18 +75,42 @@ export function AddOutfitDialog() {
.padStart(3, "0"),
)
const handleSubmit = async () => {
setIsSubmitting(true)
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 1500))
setIsSubmitting(false)
setOpen(false)
// 重置表单
setStep(1)
useEffect(() => {
if (mode === "edit" && initialOutfit) {
setName(initialOutfit.name)
setOutfitType(initialOutfit.type)
setRarity(initialOutfit.rarity)
setDescription(initialOutfit.description)
setIsLimited(initialOutfit.type === "限定服装")
setPreviewId(initialOutfit.id)
setImageUrl(isRealImageUrl(initialOutfit.image) ? initialOutfit.image : undefined)
}
}, [mode, initialOutfit])
useEffect(() => {
if (controlledOpen !== undefined) {
setOpen(controlledOpen)
}
}, [controlledOpen])
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen)
if (setControlledOpen) {
setControlledOpen(newOpen)
}
if (!newOpen && mode === "create") {
resetForm()
}
}
const resetForm = () => {
if (mode === "create") {
setName("")
setOutfitType("")
setRarity("")
setPrintQuantity(1000)
setDescription("")
setIsLimited(false)
setImageUrl(undefined)
setPreviewId(
"OFT" +
Math.floor(Math.random() * 1000)
@ -54,71 +118,96 @@ export function AddOutfitDialog() {
.padStart(3, "0"),
)
}
const handleNext = () => {
setStep(step + 1)
}
const handleBack = () => {
setStep(step - 1)
const handleSubmit = async () => {
if (!name || !outfitType || !rarity || !description) {
alert("请填写所有必填字段!")
return
}
const handleClose = () => {
setOpen(false)
setStep(1)
setIsSubmitting(true)
try {
const actualType = isLimited ? "限定服装" : outfitType
if (mode === "create") {
const created = await createOutfit({
name,
description,
category: actualType,
rarityValue: RARITY_MAP[rarity] || rarity,
imageUrl,
})
const outfit: DisplayOutfit = {
id: created.id,
name: created.name,
type: created.category || actualType,
rarity: created.rarity || rarity,
description: created.description || description,
releaseDate: created.createdAt || "",
status: created.status || "未发布",
activatedCount: created.activeCardsCount || 0,
image: created.imageUrl || "",
}
if (onSave) {
onSave(outfit)
}
} else {
const updated = await updateOutfit(initialOutfit!.id, {
name,
description,
category: actualType,
imageUrl,
})
const outfit: DisplayOutfit = {
id: updated.id,
name: updated.name,
type: updated.category || actualType,
rarity: updated.rarity || rarity,
description: updated.description || description,
releaseDate: initialOutfit?.releaseDate || "",
status: updated.status || initialOutfit?.status || "未发布",
activatedCount: updated.activeCardsCount || initialOutfit?.activatedCount || 0,
image: updated.imageUrl || initialOutfit?.image || "",
}
if (onSave) {
onSave(outfit)
}
}
handleOpenChange(false)
} catch (error) {
console.error(mode === "create" ? "创建服装失败:" : "更新服装失败:", error)
alert(mode === "create" ? "创建服装失败,请重试!" : "更新服装失败,请重试!")
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={handleOpenChange}>
{mode === "create" && (
<DialogTrigger asChild>
<Button className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg">
<Plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
)}
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
{mode === "create" ? "添加新服装" : "编辑服装"}
</DialogTitle>
<DialogDescription>ID</DialogDescription>
<DialogDescription>
{mode === "create" ? "填写服装信息以创建新的服装卡牌。创建后将生成唯一的卡牌ID。" : "修改服装信息。"}
</DialogDescription>
</DialogHeader>
<Tabs value={`step-${step}`} className="mt-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger
value="step-1"
className={cn(
"data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white",
step >= 1 ? "text-pink-600" : "text-gray-500",
)}
disabled
>
</TabsTrigger>
<TabsTrigger
value="step-2"
className={cn(
"data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white",
step >= 2 ? "text-pink-600" : "text-gray-500",
)}
disabled
>
</TabsTrigger>
<TabsTrigger
value="step-3"
className={cn(
"data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white",
step >= 3 ? "text-pink-600" : "text-gray-500",
)}
disabled
>
</TabsTrigger>
</TabsList>
<TabsContent value="step-1" className="space-y-4 py-4">
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-right">
@ -128,6 +217,8 @@ export function AddOutfitDialog() {
id="name"
placeholder="输入服装名称"
className="border-gray-300 focus-visible:ring-pink-500"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
@ -140,10 +231,12 @@ export function AddOutfitDialog() {
<SelectValue placeholder="选择服装类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="regular"></SelectItem>
<SelectItem value="seasonal"></SelectItem>
<SelectItem value="festival"></SelectItem>
<SelectItem value="special"></SelectItem>
<SelectItem value="常规服装"></SelectItem>
<SelectItem value="演出服装"></SelectItem>
<SelectItem value="季节限定"></SelectItem>
<SelectItem value="节日限定"></SelectItem>
<SelectItem value="限定服装"></SelectItem>
<SelectItem value="其他"></SelectItem>
</SelectContent>
</Select>
</div>
@ -159,26 +252,25 @@ export function AddOutfitDialog() {
<SelectValue placeholder="选择稀有度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="common"></SelectItem>
<SelectItem value="rare"></SelectItem>
<SelectItem value="epic"></SelectItem>
<SelectItem value="legendary"></SelectItem>
<SelectItem value="普通"></SelectItem>
<SelectItem value="稀有"></SelectItem>
<SelectItem value="史诗"></SelectItem>
<SelectItem value="传说"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="print-quantity" className="text-right">
<span className="text-red-500">*</span>
<Label htmlFor="quantity" className="text-right">
<span className="text-red-500">*</span>
</Label>
<Input
id="print-quantity"
id="quantity"
type="number"
min={1}
value={printQuantity}
onChange={(e) => setPrintQuantity(Number.parseInt(e.target.value))}
placeholder="输入印刷数量"
min="1"
defaultValue="1000"
className="border-gray-300 focus-visible:ring-pink-500"
required
disabled={mode === "edit"}
/>
</div>
</div>
@ -191,10 +283,55 @@ export function AddOutfitDialog() {
id="description"
placeholder="输入服装描述"
className="min-h-[100px] border-gray-300 focus-visible:ring-pink-500"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label className="text-right">
{mode === "create" && <span className="text-red-500">*</span>}
</Label>
{imageUrl ? (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center space-x-3">
<img
src={imageUrl}
alt="服装图片"
className="h-16 w-16 object-cover rounded border"
/>
<span className="text-sm text-gray-700"></span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setImageUrl(undefined)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={isSubmitting}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<FileUpload
imageOnly={true}
multiple={false}
maxFiles={1}
maxSize={5 * 1024 * 1024}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
placeholder="点击或拖拽上传服装图片"
disabled={isSubmitting}
onUploadSuccess={(files) => {
if (files.length > 0) {
setImageUrl(files[0].url)
}
}}
/>
)}
</div>
<div className="flex items-center space-x-2 pt-2">
<Switch id="limited" checked={isLimited} onCheckedChange={setIsLimited} />
<Label htmlFor="limited" className="cursor-pointer">
@ -211,118 +348,23 @@ export function AddOutfitDialog() {
</div>
</div>
)}
</TabsContent>
<TabsContent value="step-2" className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-right">
<span className="text-red-500">*</span>
</Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
<Upload className="h-8 w-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-500"></p>
<p className="text-xs text-gray-400 mt-1"> PNG, JPG, JPEG 5MB</p>
</div>
</div>
<div className="space-y-2 mt-4">
<Label className="text-right"> ()</Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
<Upload className="h-8 w-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-500"></p>
<p className="text-xs text-gray-400 mt-1">5</p>
</div>
</div>
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start mt-2">
<AlertTriangle className="h-5 w-5 text-blue-500 mr-2 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-700">
<p className="font-medium"></p>
<p>APP展示使PNG格式</p>
</div>
</div>
</TabsContent>
<TabsContent value="step-3" className="space-y-4 py-4">
{mode === "create" && (
<div className="p-4 bg-gray-50 rounded-lg">
<h3 className="text-lg font-medium mb-3"></h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500">ID</p>
<p className="font-medium text-pink-600">{previewId}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">
{outfitType === "regular"
? "常规"
: outfitType === "seasonal"
? "季节限定"
: outfitType === "festival"
? "节日限定"
: outfitType === "special"
? "特别版"
: "-"}
<h3 className="text-sm font-medium mb-2"></h3>
<p className="text-sm text-gray-600">
ID: <span className="font-medium text-pink-600">{previewId}</span>
</p>
<p className="text-sm text-gray-600 mt-1">
: <span className="font-medium"></span>
</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">
{rarity === "common"
? "普通"
: rarity === "rare"
? "稀有"
: rarity === "epic"
? "史诗"
: rarity === "legendary"
? "传说"
: "-"}
</p>
)}
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{printQuantity}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{isLimited ? "是" : "否"}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium"></p>
</div>
</div>
<div className="mt-4 p-3 bg-pink-50 border border-pink-200 rounded-lg flex items-start">
<AlertTriangle className="h-5 w-5 text-pink-500 mr-2 flex-shrink-0 mt-0.5" />
<div className="text-sm text-pink-700">
<p className="font-medium"></p>
<p>ID</p>
</div>
</div>
</div>
</TabsContent>
</Tabs>
<DialogFooter className="flex items-center justify-between mt-2">
{step > 1 ? (
<Button variant="outline" onClick={handleBack} disabled={isSubmitting}>
</Button>
) : (
<Button variant="outline" onClick={handleClose} disabled={isSubmitting}>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
</Button>
)}
{step < 3 ? (
<Button
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
onClick={handleNext}
>
</Button>
) : (
<Button
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
onClick={handleSubmit}
@ -331,13 +373,14 @@ export function AddOutfitDialog() {
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
{mode === "create" ? "创建中..." : "更新中..."}
</>
) : (
) : mode === "create" ? (
"创建服装"
) : (
"更新服装"
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -15,9 +15,25 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Plus, Upload, AlertTriangle, Loader2 } from "lucide-react"
import { Plus, AlertTriangle, Loader2, Trash2 } from "lucide-react"
import { Switch } from "@/components/ui/switch"
import { FileUpload } from "@/components/ui/file-upload"
import type { Prop } from "./prop-detail-dialog"
import { createProp, updateProp } from "@/lib/api/props"
// 判断是否为有效图片URL非placeholder
function isRealImageUrl(url?: string): boolean {
if (!url) return false
return !url.includes("placeholder")
}
// 中文稀有度 -> 后端 rarity key
const RARITY_MAP: Record<string, string> = {
"普通": "common",
"稀有": "rare",
"史诗": "epic",
"传说": "legendary",
}
type AddPropDialogProps = {
mode?: "create" | "edit"
@ -43,6 +59,7 @@ export function AddPropDialog({
const [rarity, setRarity] = useState("")
const [description, setDescription] = useState("")
const [isLimited, setIsLimited] = useState(false)
const [imageUrl, setImageUrl] = useState<string | undefined>()
const [previewId, setPreviewId] = useState(
"PRP" +
Math.floor(Math.random() * 1000)
@ -59,6 +76,7 @@ export function AddPropDialog({
setDescription(initialProp.description)
setIsLimited(initialProp.type === "限定道具")
setPreviewId(initialProp.id)
setImageUrl(isRealImageUrl(initialProp.image) ? initialProp.image : undefined)
}
}, [mode, initialProp])
@ -90,6 +108,7 @@ export function AddPropDialog({
setRarity("")
setDescription("")
setIsLimited(false)
setImageUrl(undefined)
setPreviewId(
"PRP" +
Math.floor(Math.random() * 1000)
@ -108,26 +127,59 @@ export function AddPropDialog({
setIsSubmitting(true)
try {
// 构建道具对象
const prop: Prop = {
id: initialProp?.id || previewId,
const actualType = isLimited ? "限定道具" : propType
if (mode === "create") {
// 调用后端创建 API
const created = await createProp({
name,
type: isLimited ? "限定道具" : propType,
rarity,
description,
releaseDate: initialProp?.releaseDate || "",
status: initialProp?.status || "未发布",
activatedCount: initialProp?.activatedCount || 0,
image: initialProp?.image || "/placeholder.svg?height=300&width=300",
category: actualType,
rarityValue: RARITY_MAP[rarity] || rarity,
imageUrl,
})
// 构建组件格式的 Prop 用于回调
const prop: Prop = {
id: created.id,
name: created.name,
type: created.category || actualType,
rarity: created.rarity || rarity,
description: created.description || description,
releaseDate: created.createdAt || "",
status: created.status || "未发布",
activatedCount: created.activeCardsCount || 0,
image: created.imageUrl || "/placeholder.svg?height=300&width=300",
}
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 1500))
// 调用保存回调
if (onSave) {
onSave(prop)
}
} else {
// 调用后端更新 API
const updated = await updateProp(initialProp!.id, {
name,
description,
category: actualType,
imageUrl,
})
const prop: Prop = {
id: updated.id,
name: updated.name,
type: updated.category || actualType,
rarity: updated.rarity || rarity,
description: updated.description || description,
releaseDate: initialProp?.releaseDate || "",
status: updated.status || initialProp?.status || "未发布",
activatedCount: updated.activeCardsCount || initialProp?.activatedCount || 0,
image: updated.imageUrl || initialProp?.image || "/placeholder.svg?height=300&width=300",
}
if (onSave) {
onSave(prop)
}
}
// 关闭对话框
handleOpenChange(false)
@ -243,11 +295,43 @@ export function AddPropDialog({
<Label className="text-right">
{mode === "create" && <span className="text-red-500">*</span>}
</Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
<Upload className="h-8 w-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-500"></p>
<p className="text-xs text-gray-400 mt-1"> PNG, JPG, JPEG 5MB</p>
{imageUrl ? (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center space-x-3">
<img
src={imageUrl}
alt="道具图片"
className="h-16 w-16 object-cover rounded border"
/>
<span className="text-sm text-gray-700"></span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setImageUrl(undefined)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={isSubmitting}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<FileUpload
imageOnly={true}
multiple={false}
maxFiles={1}
maxSize={5 * 1024 * 1024}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
placeholder="点击或拖拽上传道具图片"
disabled={isSubmitting}
onUploadSuccess={(files) => {
if (files.length > 0) {
setImageUrl(files[0].url)
}
}}
/>
)}
</div>
<div className="flex items-center space-x-2 pt-2">

View File

@ -1,10 +1,12 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { logout } from "@/lib/api/auth"
import { hasPermission, getUserRole, type PermissionModule } from "@/lib/permissions"
import {
Brain,
Music,
@ -21,24 +23,88 @@ import {
Heart,
Footprints,
Trophy,
type LucideIcon,
} from "lucide-react"
interface MenuItem {
label: string
href: string
icon: LucideIcon
module: PermissionModule
}
// AI 管理
const aiMenuItems: MenuItem[] = [
{ label: "大模型管理", href: "/ai-model", icon: Brain, module: "ai-model" },
]
// 内容管理
const contentMenuItems: MenuItem[] = [
{ label: "服装管理", href: "/outfits", icon: Shirt, module: "outfits" },
{ label: "道具管理", href: "/props", icon: Gift, module: "props" },
{ label: "家居装饰管理", href: "/home-decor", icon: Home, module: "home-decor" },
{ label: "歌曲管理", href: "/songs", icon: Music, module: "songs" },
{ label: "舞蹈管理", href: "/dances", icon: Footprints, module: "dances" },
{ label: "食物管理", href: "/food", icon: Utensils, module: "food" },
{ label: "成就管理", href: "/achievements", icon: Trophy, module: "achievements" },
{ label: "好感度系统", href: "/affinity", icon: Heart, module: "affinity" },
]
// 系统管理
const systemMenuItems: MenuItem[] = [
{ label: "用户管理", href: "/users", icon: User, module: "users" },
{ label: "权限管理", href: "/permissions", icon: Lock, module: "permissions" },
{ label: "系统设置", href: "/settings", icon: Settings, module: "settings" },
]
function NavButton({ item, pathname }: { item: MenuItem; pathname: string }) {
const isActive = pathname === item.href
const Icon = item.icon
return (
<Button
variant={isActive ? "default" : "ghost"}
className={cn(
"w-full justify-start",
isActive
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
)}
asChild
>
<Link href={item.href}>
<Icon className="mr-2 h-4 w-4" />
{item.label}
</Link>
</Button>
)
}
export function Sidebar() {
const pathname = usePathname()
const router = useRouter()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const handleLogout = async () => {
try {
// 调用退出登录API
await logout()
// 退出后重定向到登录页面
router.push("/login")
} catch (error) {
console.error("退出登录失败:", error)
}
}
// 根据权限过滤菜单项(挂载后才读 localStorage
const visibleAiItems = mounted ? aiMenuItems.filter((item) => hasPermission(item.module)) : []
const visibleContentItems = mounted ? contentMenuItems.filter((item) => hasPermission(item.module)) : []
const visibleSystemItems = mounted ? systemMenuItems.filter((item) => hasPermission(item.module)) : []
const role = mounted ? getUserRole() : ""
return (
<div className="flex h-screen border-r bg-gradient-to-b from-white to-gray-50 shadow-md">
<div className="flex w-full flex-col space-y-4 p-4">
@ -47,13 +113,17 @@ export function Sidebar() {
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center">
<Sparkles className="h-4 w-4 text-white" />
</div>
<div>
<h2 className="text-xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-pink-600 to-purple-600">
</h2>
<p className="text-xs text-gray-400">{role}</p>
</div>
</div>
</div>
<div className="space-y-1.5">
{/* 仪表盘 - 所有角色都可见 */}
<Button
variant={pathname === "/" ? "default" : "ghost"}
className={cn(
@ -70,209 +140,41 @@ export function Sidebar() {
</Link>
</Button>
{/* AI 管理 */}
{visibleAiItems.length > 0 && (
<>
<div className="pt-4 pb-2">
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">AI </p>
</div>
<Button
variant={pathname === "/ai-model" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/ai-model"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
{visibleAiItems.map((item) => (
<NavButton key={item.href} item={item} pathname={pathname} />
))}
</>
)}
asChild
>
<Link href="/ai-model">
<Brain className="mr-2 h-4 w-4" />
</Link>
</Button>
{/* 内容管理 */}
{visibleContentItems.length > 0 && (
<>
<div className="pt-4 pb-2">
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider"></p>
</div>
<Button
variant={pathname === "/outfits" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/outfits"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
{visibleContentItems.map((item) => (
<NavButton key={item.href} item={item} pathname={pathname} />
))}
</>
)}
asChild
>
<Link href="/outfits">
<Shirt className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button
variant={pathname === "/props" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/props"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
)}
asChild
>
<Link href="/props">
<Gift className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button
variant={pathname === "/home-decor" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/home-decor"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
)}
asChild
>
<Link href="/home-decor">
<Home className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button
variant={pathname === "/songs" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/songs"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
)}
asChild
>
<Link href="/songs">
<Music className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button
variant={pathname === "/dances" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/dances"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
)}
asChild
>
<Link href="/dances">
<Footprints className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button
variant={pathname === "/food" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/food"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
)}
asChild
>
<Link href="/food">
<Utensils className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button
variant={pathname === "/achievements" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/achievements"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
)}
asChild
>
<Link href="/achievements">
<Trophy className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button
variant={pathname === "/affinity" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/affinity"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
)}
asChild
>
<Link href="/affinity">
<Heart className="mr-2 h-4 w-4" />
</Link>
</Button>
{/* 系统管理 */}
{visibleSystemItems.length > 0 && (
<>
<div className="pt-4 pb-2">
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider"></p>
</div>
<Button
variant={pathname === "/users" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/users"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
{visibleSystemItems.map((item) => (
<NavButton key={item.href} item={item} pathname={pathname} />
))}
</>
)}
asChild
>
<Link href="/users">
<User className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button
variant={pathname === "/permissions" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/permissions"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
)}
asChild
>
<Link href="/permissions">
<Lock className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button
variant={pathname === "/settings" ? "default" : "ghost"}
className={cn(
"w-full justify-start",
pathname === "/settings"
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
: "hover:bg-gray-100",
)}
asChild
>
<Link href="/settings">
<Settings className="mr-2 h-4 w-4" />
</Link>
</Button>
</div>
<div className="mt-auto pt-4">

View File

@ -15,7 +15,8 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Plus, Upload, Loader2, Music } from "lucide-react"
import { Plus, Loader2, Music, Trash2 } from "lucide-react"
import { FileUpload } from "@/components/ui/file-upload"
import { Switch } from "@/components/ui/switch"
import type { Song } from "./song-detail-dialog"
import { uploadSongFile, createSong, updateSong } from "@/lib/api/songs"
@ -53,6 +54,7 @@ export function AddSongDialog({
.toString()
.padStart(3, "0"),
)
const [coverUrl, setCoverUrl] = useState<string>("")
const [audioFile, setAudioFile] = useState<File | null>(null)
const [audioUrl, setAudioUrl] = useState<string>("")
const [isUploading, setIsUploading] = useState(false)
@ -85,6 +87,11 @@ export function AddSongDialog({
setDescription(initialSong.description)
}
// 初始化封面URL
if (initialSong.image) {
setCoverUrl(initialSong.image)
}
// 初始化音频URL
if (initialSong.audioUrl) {
setAudioUrl(initialSong.audioUrl)
@ -187,7 +194,7 @@ export function AddSongDialog({
if (mode === "create") {
// 创建模式
const payload = {
const payload: any = {
name,
category: "song",
card_type: "regular",
@ -197,15 +204,21 @@ export function AddSongDialog({
description: description || undefined,
song_attributes: songAttributes
}
if (coverUrl) {
payload.image_url = coverUrl
}
const created = await createSong(payload)
if (onSave) onSave(created)
} else if (mode === "edit" && initialSong) {
// 编辑模式
const payload = {
const payload: any = {
name,
category: "song",
song_attributes: songAttributes
}
if (coverUrl) {
payload.image_url = coverUrl
}
const updated = await updateSong(initialSong.id, payload)
if (onSave) onSave(updated)
}
@ -299,10 +312,43 @@ export function AddSongDialog({
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<div className="border-2 border-dashed border-gray-300 rounded-md p-2 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer min-h-[60px]">
<Upload className="h-5 w-5 text-gray-400 mb-1" />
<span className="text-xs text-gray-500"></span>
{coverUrl && !coverUrl.includes("placeholder") ? (
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md border min-h-[60px]">
<div className="flex items-center space-x-2">
<img
src={coverUrl}
alt="歌曲封面"
className="h-10 w-10 object-cover rounded border"
/>
<span className="text-xs text-gray-700"></span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setCoverUrl("")}
className="text-red-500 hover:text-red-700 hover:bg-red-50 h-6 w-6 p-0"
disabled={isSubmitting}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
) : (
<FileUpload
imageOnly={true}
multiple={false}
maxFiles={1}
maxSize={5 * 1024 * 1024}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
placeholder="上传封面"
disabled={isSubmitting}
onUploadSuccess={(files) => {
if (files.length > 0) {
setCoverUrl(files[0].url)
}
}}
/>
)}
</div>
</div>
{/* 可折叠描述和预览信息 */}

View File

@ -1,266 +1,117 @@
import type { Achievement } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟成就数据
const mockAchievements: Achievement[] = [
{
id: "1",
name: "初次见面",
description: "第一次与洛天依对话",
icon: "message-circle",
category: "互动",
requirement: "与洛天依进行第一次对话",
// 将后端成就数据映射到前端 Achievement 类型
function mapBackendAchievement(a: any): Achievement {
// 后端 achievement_type → 前端 category 映射
const categoryMap: Record<string, Achievement["category"]> = {
login: "互动",
activity: "互动",
social: "互动",
usage: "收集",
special: "特殊",
hidden: "隐藏",
}
return {
id: String(a.id),
name: a.name,
description: a.description,
icon: undefined,
category: categoryMap[a.achievement_type] || "特殊",
requirement: a.conditions ? JSON.stringify(a.conditions) : "",
rewardType: "经验值",
rewardAmount: 100,
rewardIcon: "zap",
isHidden: false,
unlockRate: 98.5,
createdAt: "2023-01-01",
updatedAt: "2023-01-01",
},
{
id: "2",
name: "亲密无间",
description: "与洛天依好感度达到80",
icon: "heart",
category: "好感度",
requirement: "将洛天依的好感度提升至80",
rewardType: "虚拟币",
rewardAmount: 500,
rewardIcon: "coins",
isHidden: false,
unlockRate: 45.2,
createdAt: "2023-01-02",
updatedAt: "2023-01-02",
},
{
id: "3",
name: "形影不离",
description: "与洛天依好感度达到100",
icon: "heart-handshake",
category: "好感度",
requirement: "将洛天依的好感度提升至100",
rewardType: "称号",
rewardAmount: 1,
rewardIcon: "badge",
isHidden: false,
unlockRate: 12.8,
createdAt: "2023-01-03",
updatedAt: "2023-01-03",
},
{
id: "4",
name: "热情如火",
description: "与洛天依单日互动达到50次",
icon: "flame",
category: "互动",
requirement: "在一天内与洛天依互动50次",
rewardType: "好感度",
rewardAmount: 20,
rewardIcon: "heart",
isHidden: false,
unlockRate: 23.7,
createdAt: "2023-01-04",
updatedAt: "2023-01-04",
},
{
id: "5",
name: "坚持不懈",
description: "连续7天与洛天依互动",
icon: "calendar-check",
category: "互动",
requirement: "连续7天每天至少与洛天依互动1次",
rewardType: "道具",
rewardAmount: 1,
rewardIcon: "gift",
isHidden: false,
unlockRate: 34.1,
createdAt: "2023-01-05",
updatedAt: "2023-01-05",
},
{
id: "6",
name: "时尚达人",
description: "收集10套服装",
icon: "shirt",
category: "收集",
requirement: "收集10套不同的服装",
rewardType: "服装",
rewardAmount: 1,
rewardIcon: "shirt",
isHidden: false,
unlockRate: 56.3,
createdAt: "2023-01-06",
updatedAt: "2023-01-06",
},
{
id: "7",
name: "音乐鉴赏家",
description: "收听20首不同的歌曲",
icon: "music",
category: "收集",
requirement: "收听20首不同的歌曲",
rewardType: "虚拟币",
rewardAmount: 300,
rewardIcon: "coins",
isHidden: false,
unlockRate: 42.9,
createdAt: "2023-01-07",
updatedAt: "2023-01-07",
},
{
id: "8",
name: "舞动青春",
description: "观看10支不同的舞蹈",
icon: "footprints",
category: "收集",
requirement: "观看10支不同的舞蹈表演",
rewardType: "经验值",
rewardAmount: 200,
rewardIcon: "zap",
isHidden: false,
unlockRate: 38.5,
createdAt: "2023-01-08",
updatedAt: "2023-01-08",
},
{
id: "9",
name: "美食家",
description: "收集15种不同的食物",
icon: "utensils",
category: "收集",
requirement: "收集15种不同的食物",
rewardType: "道具",
rewardAmount: 1,
rewardIcon: "gift",
isHidden: false,
unlockRate: 27.4,
createdAt: "2023-01-09",
updatedAt: "2023-01-09",
},
{
id: "10",
name: "隐藏的秘密",
description: "发现洛天依的秘密",
icon: "sparkles",
category: "隐藏",
requirement: "???",
rewardType: "称号",
rewardAmount: 1,
rewardIcon: "badge",
isHidden: true,
unlockRate: 5.2,
createdAt: "2023-01-10",
updatedAt: "2023-01-10",
},
]
rewardAmount: a.points || 0,
isHidden: a.is_hidden || false,
unlockRate: undefined,
createdAt: a.created_at ? a.created_at.split("T")[0] : "",
updatedAt: a.updated_at ? a.updated_at.split("T")[0] : "",
}
}
// 获取成就列表
export const getAchievements = async (params?: PaginationParams): Promise<PaginatedResponse<Achievement>> => {
let filteredAchievements = [...mockAchievements]
// 搜索过滤
if (params?.search) {
const search = params.search.toLowerCase()
filteredAchievements = filteredAchievements.filter(
(achievement) =>
achievement.name.toLowerCase().includes(search) ||
achievement.description.toLowerCase().includes(search) ||
achievement.category.toLowerCase().includes(search),
)
}
// 排序
if (params?.sortBy) {
filteredAchievements.sort((a: any, b: any) => {
const aValue = a[params.sortBy!]
const bValue = b[params.sortBy!]
if (typeof aValue === "string" && typeof bValue === "string") {
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
}
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
})
}
// 分页
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize
const paginatedAchievements = filteredAchievements.slice(startIndex, startIndex + pageSize)
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
return mockResponse({
items: paginatedAchievements,
total: filteredAchievements.length,
const response = await apiClient.get(
`/achievement/achievements/?page=${page}&page_size=${pageSize}&show_hidden=true${searchParam}`
)
const data = response.data?.data || response.data
const results: any[] = data.results || data
const total = data.count || results.length
return {
items: results.map(mapBackendAchievement),
total,
page,
pageSize,
totalPages: Math.ceil(filteredAchievements.length / pageSize),
})
totalPages: Math.ceil(total / pageSize),
}
}
// 获取单个成就
export const getAchievement = async (id: string): Promise<Achievement> => {
const achievement = mockAchievements.find((achievement) => achievement.id === id)
if (!achievement) {
return mockResponse({} as Achievement, "成就不存在")
const response = await apiClient.get(`/achievement/achievements/${id}/`)
const data = response.data?.data || response.data
return mapBackendAchievement(data)
}
return mockResponse(achievement)
}
// 创建成就
// 创建成就(需要管理员权限)
export const createAchievement = async (achievementData: Partial<Achievement>): Promise<Achievement> => {
const newAchievement: Achievement = {
id: String(mockAchievements.length + 1),
name: achievementData.name || "",
description: achievementData.description || "",
icon: achievementData.icon || "award",
category: achievementData.category || "特殊",
requirement: achievementData.requirement || "",
rewardType: achievementData.rewardType || "经验值",
rewardAmount: achievementData.rewardAmount || 0,
rewardIcon: achievementData.rewardIcon,
isHidden: achievementData.isHidden || false,
unlockRate: 0,
createdAt: new Date().toISOString().split("T")[0],
updatedAt: new Date().toISOString().split("T")[0],
const typeReverseMap: Record<string, string> = {
: "activity",
: "activity",
: "usage",
: "activity",
: "special",
: "hidden",
}
mockAchievements.push(newAchievement)
return mockResponse(newAchievement)
const payload = {
name: achievementData.name,
description: achievementData.description,
achievement_type: typeReverseMap[achievementData.category || "特殊"] || "special",
is_hidden: achievementData.isHidden || false,
points: achievementData.rewardAmount || 0,
rarity: "common",
conditions: achievementData.requirement ? { description: achievementData.requirement } : {},
}
// 更新成就
const response = await apiClient.post(`/achievement/achievements/`, payload)
const data = response.data?.data || response.data
return mapBackendAchievement(data)
}
// 更新成就(需要管理员权限)
export const updateAchievement = async (id: string, achievementData: Partial<Achievement>): Promise<Achievement> => {
const achievementIndex = mockAchievements.findIndex((achievement) => achievement.id === id)
if (achievementIndex === -1) {
return mockResponse({} as Achievement, "成就不存在")
const typeReverseMap: Record<string, string> = {
: "activity",
: "activity",
: "usage",
: "activity",
: "special",
: "hidden",
}
const updatedAchievement = {
...mockAchievements[achievementIndex],
...achievementData,
updatedAt: new Date().toISOString().split("T")[0],
const payload: any = {}
if (achievementData.name !== undefined) payload.name = achievementData.name
if (achievementData.description !== undefined) payload.description = achievementData.description
if (achievementData.category !== undefined) payload.achievement_type = typeReverseMap[achievementData.category] || "special"
if (achievementData.isHidden !== undefined) payload.is_hidden = achievementData.isHidden
if (achievementData.rewardAmount !== undefined) payload.points = achievementData.rewardAmount
if (achievementData.requirement !== undefined) payload.conditions = { description: achievementData.requirement }
const response = await apiClient.patch(`/achievement/achievements/${id}/`, payload)
const data = response.data?.data || response.data
return mapBackendAchievement(data)
}
mockAchievements[achievementIndex] = updatedAchievement
return mockResponse(updatedAchievement)
}
// 删除成就
// 删除成就(需要管理员权限)
export const deleteAchievement = async (id: string): Promise<boolean> => {
const achievementIndex = mockAchievements.findIndex((achievement) => achievement.id === id)
if (achievementIndex === -1) {
return mockResponse(false, "成就不存在")
}
mockAchievements.splice(achievementIndex, 1)
return mockResponse(true)
await apiClient.delete(`/achievement/achievements/${id}/`)
return true
}

View File

@ -1,306 +1,138 @@
import type { AffinityRule, AffinityLevel } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟好感度规则数据
const mockAffinityRules: AffinityRule[] = [
{
id: "AFR001",
name: "每日登录",
description: "用户每日登录系统可获得好感度",
points: 10,
type: "日常活动",
status: "已启用",
},
{
id: "AFR002",
name: "赠送礼物",
description: "用户赠送礼物可获得好感度",
points: 20,
type: "互动活动",
status: "已启用",
},
{
id: "AFR003",
name: "完成任务",
description: "用户完成指定任务可获得好感度",
points: 15,
type: "任务活动",
status: "已启用",
},
{
id: "AFR004",
name: "参与活动",
description: "用户参与系统活动可获得好感度",
points: 30,
type: "特殊活动",
status: "已启用",
},
{
id: "AFR005",
name: "购买道具",
description: "用户购买道具可获得好感度",
points: 25,
type: "消费活动",
status: "未启用",
},
]
// 将后端 AffinityRule 数据映射到前端类型
function mapBackendRule(r: any): AffinityRule {
return {
id: String(r.id),
action: r.name,
points: r.points || 0,
description: r.description || "",
dailyLimit: r.daily_limit ?? undefined,
}
}
// 模拟好感度等级数据
const mockAffinityLevels: AffinityLevel[] = [
{
id: "AFL001",
level: 1,
name: "初识",
pointsRequired: 0,
rewards: ["基础表情包", "基础头像框"],
description: "刚刚认识洛天依,开始了解她的世界。",
},
{
id: "AFL002",
level: 2,
name: "相识",
pointsRequired: 100,
rewards: ["语音问候", "特殊表情包"],
description: "与洛天依有了初步的交流,开始建立友谊。",
},
{
id: "AFL003",
level: 3,
name: "熟悉",
pointsRequired: 300,
rewards: ["专属头像框", "房间装饰道具"],
description: "与洛天依建立了稳定的友谊,互相了解更多。",
},
{
id: "AFL004",
level: 4,
name: "亲密",
pointsRequired: 600,
rewards: ["专属服装", "专属互动动画"],
description: "与洛天依建立了深厚的友谊,成为了亲密的朋友。",
},
{
id: "AFL005",
level: 5,
name: "挚友",
pointsRequired: 1000,
rewards: ["限定服装", "专属歌曲", "特殊互动场景"],
description: "与洛天依建立了最亲密的关系,成为了无可替代的挚友。",
},
]
// 将后端 AffinityLevel 数据映射到前端类型
function mapBackendLevel(l: any): AffinityLevel {
return {
id: String(l.id),
level: l.level,
name: l.name,
description: l.description || "",
requiredPoints: l.required_points,
rewards: Array.isArray(l.rewards) ? l.rewards : [],
}
}
// ── 好感度规则 ────────────────────────────────────────────
// 获取好感度规则列表
export const getAffinityRules = async (params?: PaginationParams): Promise<PaginatedResponse<AffinityRule>> => {
let filteredRules = [...mockAffinityRules]
// 搜索过滤
if (params?.search) {
const search = params.search.toLowerCase()
filteredRules = filteredRules.filter(
(rule) =>
rule.name.toLowerCase().includes(search) ||
rule.description.toLowerCase().includes(search) ||
rule.type.toLowerCase().includes(search),
)
}
// 排序
if (params?.sortBy) {
filteredRules.sort((a: any, b: any) => {
const aValue = a[params.sortBy!]
const bValue = b[params.sortBy!]
if (typeof aValue === "string" && typeof bValue === "string") {
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
}
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
})
}
// 分页
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize
const paginatedRules = filteredRules.slice(startIndex, startIndex + pageSize)
return mockResponse({
items: paginatedRules,
total: filteredRules.length,
const response = await apiClient.get(`/user/affinity-rules/?page=${page}&page_size=${pageSize}`)
const data = response.data?.data || response.data
const results: any[] = data.results || (Array.isArray(data) ? data : [])
const total = data.count || results.length
return {
items: results.map(mapBackendRule),
total,
page,
pageSize,
totalPages: Math.ceil(filteredRules.length / pageSize),
})
totalPages: Math.ceil(total / pageSize),
}
}
// 获取单个好感度规则
export const getAffinityRule = async (id: string): Promise<AffinityRule> => {
const rule = mockAffinityRules.find((rule) => rule.id === id)
if (!rule) {
return mockResponse({} as AffinityRule, "好感度规则不存在")
const response = await apiClient.get(`/user/affinity-rules/${id}/`)
const data = response.data?.data || response.data
return mapBackendRule(data)
}
return mockResponse(rule)
}
// 创建好感度规则
export const createAffinityRule = async (ruleData: Partial<AffinityRule>): Promise<AffinityRule> => {
// 生成新的好感度规则ID
const ruleId = "AFR" + String(mockAffinityRules.length + 1).padStart(3, "0")
const newRule: AffinityRule = {
id: ruleId,
name: ruleData.name || "",
const payload = {
name: ruleData.action,
description: ruleData.description || "",
points: ruleData.points || 0,
type: ruleData.type || "",
status: ruleData.status || "未启用",
daily_limit: ruleData.dailyLimit ?? null,
is_active: true,
}
const response = await apiClient.post(`/user/affinity-rules/`, payload)
const data = response.data?.data || response.data
return mapBackendRule(data)
}
mockAffinityRules.push(newRule)
return mockResponse(newRule)
}
// 更新好感度规则
export const updateAffinityRule = async (id: string, ruleData: Partial<AffinityRule>): Promise<AffinityRule> => {
const ruleIndex = mockAffinityRules.findIndex((rule) => rule.id === id)
const payload: any = {}
if (ruleData.action !== undefined) payload.name = ruleData.action
if (ruleData.description !== undefined) payload.description = ruleData.description
if (ruleData.points !== undefined) payload.points = ruleData.points
if (ruleData.dailyLimit !== undefined) payload.daily_limit = ruleData.dailyLimit
if (ruleIndex === -1) {
return mockResponse({} as AffinityRule, "好感度规则不存在")
const response = await apiClient.patch(`/user/affinity-rules/${id}/`, payload)
const data = response.data?.data || response.data
return mapBackendRule(data)
}
const updatedRule = {
...mockAffinityRules[ruleIndex],
...ruleData,
}
mockAffinityRules[ruleIndex] = updatedRule
return mockResponse(updatedRule)
}
// 删除好感度规则
export const deleteAffinityRule = async (id: string): Promise<boolean> => {
const ruleIndex = mockAffinityRules.findIndex((rule) => rule.id === id)
if (ruleIndex === -1) {
return mockResponse(false, "好感度规则不存在")
await apiClient.delete(`/user/affinity-rules/${id}/`)
return true
}
mockAffinityRules.splice(ruleIndex, 1)
// ── 好感度等级 ────────────────────────────────────────────
return mockResponse(true)
}
// 获取好感度等级列表
export const getAffinityLevels = async (params?: PaginationParams): Promise<PaginatedResponse<AffinityLevel>> => {
let filteredLevels = [...mockAffinityLevels]
// 搜索过滤
if (params?.search) {
const search = params.search.toLowerCase()
filteredLevels = filteredLevels.filter(
(level) => level.name.toLowerCase().includes(search) || level.description.toLowerCase().includes(search),
)
}
// 排序
if (params?.sortBy) {
filteredLevels.sort((a: any, b: any) => {
const aValue = a[params.sortBy!]
const bValue = b[params.sortBy!]
if (typeof aValue === "string" && typeof bValue === "string") {
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
}
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
})
} else {
// 默认按等级排序
filteredLevels.sort((a, b) => a.level - b.level)
}
// 分页
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize
const paginatedLevels = filteredLevels.slice(startIndex, startIndex + pageSize)
const pageSize = params?.pageSize || 50 // 等级数量通常较少,一次全取
return mockResponse({
items: paginatedLevels,
total: filteredLevels.length,
const response = await apiClient.get(`/user/affinity-levels/?page=${page}&page_size=${pageSize}`)
const data = response.data?.data || response.data
const results: any[] = data.results || (Array.isArray(data) ? data : [])
const total = data.count || results.length
return {
items: results.map(mapBackendLevel),
total,
page,
pageSize,
totalPages: Math.ceil(filteredLevels.length / pageSize),
})
totalPages: Math.ceil(total / pageSize),
}
}
// 获取单个好感度等级
export const getAffinityLevel = async (id: string): Promise<AffinityLevel> => {
const level = mockAffinityLevels.find((level) => level.id === id)
if (!level) {
return mockResponse({} as AffinityLevel, "好感度等级不存在")
const response = await apiClient.get(`/user/affinity-levels/${id}/`)
const data = response.data?.data || response.data
return mapBackendLevel(data)
}
return mockResponse(level)
}
// 创建好感度等级
export const createAffinityLevel = async (levelData: Partial<AffinityLevel>): Promise<AffinityLevel> => {
// 生成新的好感度等级ID
const levelId = "AFL" + String(mockAffinityLevels.length + 1).padStart(3, "0")
const newLevel: AffinityLevel = {
id: levelId,
level: levelData.level || mockAffinityLevels.length + 1,
name: levelData.name || "",
pointsRequired: levelData.pointsRequired || 0,
rewards: levelData.rewards || [],
const payload = {
level: levelData.level,
name: levelData.name,
description: levelData.description || "",
required_points: levelData.requiredPoints || 0,
rewards: levelData.rewards || [],
}
const response = await apiClient.post(`/user/affinity-levels/`, payload)
const data = response.data?.data || response.data
return mapBackendLevel(data)
}
mockAffinityLevels.push(newLevel)
// 按等级排序
mockAffinityLevels.sort((a, b) => a.level - b.level)
return mockResponse(newLevel)
}
// 更新好感度等级
export const updateAffinityLevel = async (id: string, levelData: Partial<AffinityLevel>): Promise<AffinityLevel> => {
const levelIndex = mockAffinityLevels.findIndex((level) => level.id === id)
const payload: any = {}
if (levelData.name !== undefined) payload.name = levelData.name
if (levelData.description !== undefined) payload.description = levelData.description
if (levelData.requiredPoints !== undefined) payload.required_points = levelData.requiredPoints
if (levelData.rewards !== undefined) payload.rewards = levelData.rewards
if (levelIndex === -1) {
return mockResponse({} as AffinityLevel, "好感度等级不存在")
const response = await apiClient.patch(`/user/affinity-levels/${id}/`, payload)
const data = response.data?.data || response.data
return mapBackendLevel(data)
}
const updatedLevel = {
...mockAffinityLevels[levelIndex],
...levelData,
}
mockAffinityLevels[levelIndex] = updatedLevel
// 按等级排序
mockAffinityLevels.sort((a, b) => a.level - b.level)
return mockResponse(updatedLevel)
}
// 删除好感度等级
export const deleteAffinityLevel = async (id: string): Promise<boolean> => {
const levelIndex = mockAffinityLevels.findIndex((level) => level.id === id)
if (levelIndex === -1) {
return mockResponse(false, "好感度等级不存在")
}
mockAffinityLevels.splice(levelIndex, 1)
return mockResponse(true)
await apiClient.delete(`/user/affinity-levels/${id}/`)
return true
}

View File

@ -1,185 +1,84 @@
import type { AiModel } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟AI模型数据
const mockAiModels: AiModel[] = [
{
id: "AI001",
name: "洛天依语音合成模型V1",
// 将后端 Bot 数据映射到前端 AiModel 类型
// 后端 Bot 只有 id, name, description其他字段用默认值
function mapBackendBot(b: any): AiModel {
return {
id: String(b.id),
name: b.name,
version: "1.0.0",
type: "语音合成",
status: "已发布",
createdAt: "2023-01-15",
updatedAt: "2023-01-15",
description: "洛天依的基础语音合成模型,支持基本的歌声合成功能。",
},
{
id: "AI002",
name: "洛天依对话模型V1",
version: "1.0.0",
type: "对话模型",
status: "已发布",
createdAt: "2023-03-20",
updatedAt: "2023-03-20",
description: "洛天依的基础对话模型,支持简单的问答交互。",
},
{
id: "AI003",
name: "洛天依语音合成模型V2",
version: "2.0.0",
type: "语音合成",
status: "已发布",
createdAt: "2023-06-10",
updatedAt: "2023-06-10",
description: "洛天依的进阶语音合成模型,支持更自然的歌声合成和情感表达。",
},
{
id: "AI004",
name: "洛天依对话模型V2",
version: "2.0.0",
type: "对话模型",
status: "已发布",
createdAt: "2023-09-05",
updatedAt: "2023-09-05",
description: "洛天依的进阶对话模型,支持更复杂的对话和情感理解。",
},
{
id: "AI005",
name: "洛天依多模态模型V1",
version: "1.0.0",
type: "多模态模型",
status: "开发中",
createdAt: "2023-12-01",
updatedAt: "2023-12-01",
description: "洛天依的多模态模型,支持图像识别和生成,以及与语音、文本的交互。",
},
]
description: b.description || "",
status: "活跃",
parameters: undefined,
accuracy: undefined,
trainingData: undefined,
createdAt: undefined,
updatedAt: undefined,
}
}
// 获取AI模型列表
// 获取AI模型(Bot)列表
export const getAiModels = async (params?: PaginationParams): Promise<PaginatedResponse<AiModel>> => {
let filteredModels = [...mockAiModels]
// 搜索过滤
if (params?.search) {
const search = params.search.toLowerCase()
filteredModels = filteredModels.filter(
(model) =>
model.name.toLowerCase().includes(search) ||
model.id.toLowerCase().includes(search) ||
model.type.toLowerCase().includes(search),
)
}
// 排序
if (params?.sortBy) {
filteredModels.sort((a: any, b: any) => {
const aValue = a[params.sortBy!]
const bValue = b[params.sortBy!]
if (typeof aValue === "string" && typeof bValue === "string") {
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
}
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
})
}
// 分页
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize
const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize)
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
return mockResponse({
items: paginatedModels,
total: filteredModels.length,
const response = await apiClient.get(
`/ai/bots/?page=${page}&page_size=${pageSize}${searchParam}`
)
const data = response.data?.data || response.data
const results: any[] = data.results || (Array.isArray(data) ? data : [data])
const total = data.count || results.length
return {
items: results.map(mapBackendBot),
total,
page,
pageSize,
totalPages: Math.ceil(filteredModels.length / pageSize),
})
totalPages: Math.ceil(total / pageSize),
}
}
// 获取单个AI模型
export const getAiModel = async (id: string): Promise<AiModel> => {
const model = mockAiModels.find((model) => model.id === id)
if (!model) {
return mockResponse({} as AiModel, "AI模型不存在")
const response = await apiClient.get(`/ai/bots/${id}/`)
const data = response.data?.data || response.data
return mapBackendBot(data)
}
return mockResponse(model)
}
// 创建AI模型
// 创建AI模型需要管理员权限
export const createAiModel = async (modelData: Partial<AiModel>): Promise<AiModel> => {
// 生成新的AI模型ID
const modelId = "AI" + String(mockAiModels.length + 1).padStart(3, "0")
const now = new Date().toISOString().split("T")[0]
const newModel: AiModel = {
id: modelId,
name: modelData.name || "",
version: modelData.version || "1.0.0",
type: modelData.type || "",
status: modelData.status || "开发中",
createdAt: now,
updatedAt: now,
const payload = {
name: modelData.name,
description: modelData.description || "",
}
mockAiModels.push(newModel)
return mockResponse(newModel)
const response = await apiClient.post(`/ai/bots/`, payload)
const data = response.data?.data || response.data
return mapBackendBot(data)
}
// 更新AI模型
// 更新AI模型需要管理员权限
export const updateAiModel = async (id: string, modelData: Partial<AiModel>): Promise<AiModel> => {
const modelIndex = mockAiModels.findIndex((model) => model.id === id)
const payload: any = {}
if (modelData.name !== undefined) payload.name = modelData.name
if (modelData.description !== undefined) payload.description = modelData.description
if (modelIndex === -1) {
return mockResponse({} as AiModel, "AI模型不存在")
const response = await apiClient.patch(`/ai/bots/${id}/`, payload)
const data = response.data?.data || response.data
return mapBackendBot(data)
}
const updatedModel = {
...mockAiModels[modelIndex],
...modelData,
updatedAt: new Date().toISOString().split("T")[0],
}
mockAiModels[modelIndex] = updatedModel
return mockResponse(updatedModel)
}
// 删除AI模型
// 删除AI模型需要管理员权限
export const deleteAiModel = async (id: string): Promise<boolean> => {
const modelIndex = mockAiModels.findIndex((model) => model.id === id)
if (modelIndex === -1) {
return mockResponse(false, "AI模型不存在")
await apiClient.delete(`/ai/bots/${id}/`)
return true
}
// 已发布的AI模型不能删除
if (mockAiModels[modelIndex].status === "已发布") {
return mockResponse(false, "已发布的AI模型不能删除")
}
mockAiModels.splice(modelIndex, 1)
return mockResponse(true)
}
// 发布AI模型
// 发布AI模型Bot没有发布概念这里直接返回当前数据
export const publishAiModel = async (id: string): Promise<AiModel> => {
const modelIndex = mockAiModels.findIndex((model) => model.id === id)
if (modelIndex === -1) {
return mockResponse({} as AiModel, "AI模型不存在")
}
mockAiModels[modelIndex].status = "已发布"
mockAiModels[modelIndex].updatedAt = new Date().toISOString().split("T")[0]
return mockResponse(mockAiModels[modelIndex])
return getAiModel(id)
}

View File

@ -10,6 +10,8 @@ export interface EmailLoginResponse {
code: number;
data: {
token: string;
is_superuser?: boolean;
role?: string;
};
message: string;
}
@ -42,15 +44,34 @@ export const emailLogin = async (email: string, password: string): Promise<Email
* Cookie
* @param token 访
*/
export const saveAuthToken = (token: string): void => {
export const saveAuthToken = (token: string, isSuperUser?: boolean, role?: string): void => {
// 保存到localStorage
localStorage.setItem("auth_token", token);
// 保存超级管理员标识
if (isSuperUser !== undefined) {
localStorage.setItem("is_superuser", isSuperUser ? "true" : "false");
}
// 保存用户角色
if (role) {
localStorage.setItem("user_role", role);
}
// 保存到Cookie以便middleware可以访问
// 过期时间设为7天
Cookies.set("auth_token", token, { expires: 7, path: '/' });
};
/**
*
* @returns
*/
export const isSuperUser = (): boolean => {
if (typeof window === "undefined") return false;
return localStorage.getItem("is_superuser") === "true";
};
/**
*
* @returns
@ -67,6 +88,8 @@ export const getAuthToken = (): string | null => {
*/
export const clearAuthToken = (): void => {
localStorage.removeItem("auth_token");
localStorage.removeItem("is_superuser");
localStorage.removeItem("user_role");
Cookies.remove("auth_token", { path: '/' });
};

View File

@ -50,14 +50,14 @@ apiClient.interceptors.response.use(
(error) => {
console.error('❌ 响应错误:', error.response?.status, error.config?.url);
// 处理401未授权错误
// 处理401未授权错误token过期或无效
if (error.response?.status === 401) {
console.warn('🚫 认证失败token可能过期或无效');
console.log('响应详情:', error.response?.data);
// 暂时注释掉自动清除,方便调试
// localStorage.removeItem('auth_token');
// 可以选择重定向到登录页
// window.location.href = '/login';
localStorage.removeItem('auth_token');
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error);

View File

@ -1,193 +1,46 @@
import type { Dance } from "./types"
import { apiClient } from "./client"
import { handleApiError } from "./error-handler"
// 模拟舞蹈数据
const mockDances: Dance[] = [
{
id: "1",
name: "千本樱",
choreographer: "洛天依工作室",
duration: "3:45",
difficulty: "中等",
videoUrl: "/placeholder.svg?height=300&width=400",
coverUrl: "/placeholder.svg?height=300&width=400",
description: "基于《千本樱》歌曲的经典舞蹈编排,动作流畅优美,适合中等水平的舞者。",
motionFile: "senbonzakura_motion.fbx",
category: "日式",
tags: ["经典", "流行", "日式"],
createdAt: "2023-01-15T08:30:00Z",
updatedAt: "2023-02-20T14:15:00Z",
},
{
id: "2",
name: "权御天下",
choreographer: "洛天依动作组",
duration: "4:20",
difficulty: "高级",
videoUrl: "/placeholder.svg?height=300&width=400",
coverUrl: "/placeholder.svg?height=300&width=400",
description: "中国风舞蹈,动作幅度大,需要较高的舞蹈基础,展现古风韵味。",
motionFile: "quanyutianxia_motion.fbx",
category: "中国风",
tags: ["高难度", "中国风", "古风"],
createdAt: "2023-03-10T10:45:00Z",
updatedAt: "2023-04-05T16:30:00Z",
},
{
id: "3",
name: "达拉崩吧",
choreographer: "洛天依舞蹈工作室",
duration: "3:10",
difficulty: "初级",
videoUrl: "/placeholder.svg?height=300&width=400",
coverUrl: "/placeholder.svg?height=300&width=400",
description: "轻松欢快的舞蹈,适合初学者,动作简单易学。",
motionFile: "dalabengba_motion.fbx",
category: "流行",
tags: ["简单", "欢快", "流行"],
createdAt: "2023-05-20T09:15:00Z",
updatedAt: "2023-05-25T11:20:00Z",
},
{
id: "4",
name: "普通DISCO",
choreographer: "洛天依动作设计组",
duration: "3:30",
difficulty: "中等",
videoUrl: "/placeholder.svg?height=300&width=400",
coverUrl: "/placeholder.svg?height=300&width=400",
description: "现代disco风格舞蹈节奏感强动作活力四射。",
motionFile: "disco_motion.fbx",
category: "现代",
tags: ["活力", "现代", "disco"],
createdAt: "2023-06-05T14:30:00Z",
updatedAt: "2023-06-10T17:45:00Z",
},
{
id: "5",
name: "华灯宴",
choreographer: "古风舞蹈团队",
duration: "4:50",
difficulty: "高级",
videoUrl: "/placeholder.svg?height=300&width=400",
coverUrl: "/placeholder.svg?height=300&width=400",
description: "古风舞蹈,动作优美细腻,需要较高的舞蹈技巧和表现力。",
motionFile: "hualangyan_motion.fbx",
category: "中国风",
tags: ["古风", "优美", "高难度"],
createdAt: "2023-07-12T11:20:00Z",
updatedAt: "2023-07-18T13:40:00Z",
},
{
id: "6",
name: "炉心融解",
choreographer: "日系舞蹈组",
duration: "3:55",
difficulty: "中高级",
videoUrl: "/placeholder.svg?height=300&width=400",
coverUrl: "/placeholder.svg?height=300&width=400",
description: "日系风格舞蹈,动作精准,需要良好的节奏感和协调性。",
motionFile: "meltdown_motion.fbx",
category: "日式",
tags: ["日系", "精准", "流行"],
createdAt: "2023-08-03T16:10:00Z",
updatedAt: "2023-08-10T09:25:00Z",
},
{
id: "7",
name: "芒种",
choreographer: "中国舞蹈团",
duration: "4:05",
difficulty: "中等",
videoUrl: "/placeholder.svg?height=300&width=400",
coverUrl: "/placeholder.svg?height=300&width=400",
description: "中国传统节气主题舞蹈,动作舒缓优美,富有诗意。",
motionFile: "mangzhong_motion.fbx",
category: "中国风",
tags: ["传统", "优美", "诗意"],
createdAt: "2023-09-15T10:30:00Z",
updatedAt: "2023-09-20T14:15:00Z",
},
{
id: "8",
name: "寄明月",
choreographer: "古风舞蹈设计师",
duration: "4:30",
difficulty: "高级",
videoUrl: "/placeholder.svg?height=300&width=400",
coverUrl: "/placeholder.svg?height=300&width=400",
description: "古风舞蹈,动作优美流畅,需要较高的舞蹈功底和表现力。",
motionFile: "jimingyue_motion.fbx",
category: "中国风",
tags: ["古风", "优美", "高难度"],
createdAt: "2023-10-08T13:45:00Z",
updatedAt: "2023-10-15T11:20:00Z",
},
{
id: "9",
name: "极乐净土",
choreographer: "日系动作组",
duration: "3:40",
difficulty: "中等",
videoUrl: "/placeholder.svg?height=300&width=400",
coverUrl: "/placeholder.svg?height=300&width=400",
description: "日系风格舞蹈,动作活泼可爱,节奏感强。",
motionFile: "jiletutu_motion.fbx",
category: "日式",
tags: ["日系", "活泼", "流行"],
createdAt: "2023-11-05T15:30:00Z",
updatedAt: "2023-11-12T10:45:00Z",
},
{
id: "10",
name: "牵丝戏",
choreographer: "中国古典舞团",
duration: "4:15",
difficulty: "高级",
videoUrl: "/placeholder.svg?height=300&width=400",
coverUrl: "/placeholder.svg?height=300&width=400",
description: "中国古典舞蹈,动作细腻优美,需要较高的舞蹈技巧和表现力。",
motionFile: "qiansixi_motion.fbx",
category: "中国风",
tags: ["古典", "优美", "高难度"],
createdAt: "2023-12-10T09:20:00Z",
updatedAt: "2023-12-18T14:35:00Z",
},
]
// 将后端 CardTemplate(dance) 数据映射到前端 Dance 类型
function mapBackendDance(item: any): Dance {
const attrs = item.attributes || {}
return {
id: String(item.id),
name: item.name,
choreographer: attrs.choreographer || "",
duration: attrs.duration || "",
difficulty: attrs.difficulty || "",
videoUrl: attrs.tutorial_video || item.model_url || "",
coverUrl: item.image_url || "/placeholder.svg?height=300&width=400",
description: item.description || "",
motionFile: item.model_url || "",
category: attrs.style || "",
tags: [],
createdAt: item.created_at || "",
updatedAt: item.updated_at || "",
}
}
// 获取舞蹈列表
export async function getDances(page = 1, limit = 10, search = "") {
try {
// 模拟API请求延迟
await new Promise((resolve) => setTimeout(resolve, 500))
let filteredDances = [...mockDances]
// 如果有搜索关键词,过滤结果
if (search) {
const searchLower = search.toLowerCase()
filteredDances = filteredDances.filter(
(dance) =>
dance.name.toLowerCase().includes(searchLower) ||
dance.choreographer?.toLowerCase().includes(searchLower) ||
dance.category?.toLowerCase().includes(searchLower) ||
dance.tags?.some((tag) => tag.toLowerCase().includes(searchLower)),
const searchParam = search ? `&search=${encodeURIComponent(search)}` : ""
const response = await apiClient.get(
`/card/category/dance/?page=${page}&page_size=${limit}${searchParam}`
)
}
// 计算分页
const totalItems = filteredDances.length
const totalPages = Math.ceil(totalItems / limit)
const startIndex = (page - 1) * limit
const paginatedDances = filteredDances.slice(startIndex, startIndex + limit)
const data = response.data?.data || response.data
const results: any[] = data.results || data
const total = data.count || results.length
return {
data: paginatedDances,
data: results.map(mapBackendDance),
pagination: {
page,
limit,
totalItems,
totalPages,
totalItems: total,
totalPages: Math.ceil(total / limit),
},
}
} catch (error) {
@ -198,90 +51,70 @@ export async function getDances(page = 1, limit = 10, search = "") {
// 获取单个舞蹈详情
export async function getDance(id: string) {
try {
// 模拟API请求延迟
await new Promise((resolve) => setTimeout(resolve, 300))
const dance = mockDances.find((dance) => dance.id === id)
if (!dance) {
throw new Error("舞蹈不存在")
}
return { data: dance }
const response = await apiClient.get(`/card/templates/${id}/`)
const data = response.data?.data || response.data
return { data: mapBackendDance(data) }
} catch (error) {
return handleApiError(error)
}
}
// 创建新舞蹈
// 创建新舞蹈(需要管理员权限)
export async function createDance(danceData: Omit<Dance, "id" | "createdAt" | "updatedAt">) {
try {
// 模拟API请求延迟
await new Promise((resolve) => setTimeout(resolve, 700))
const newDance: Dance = {
id: `${mockDances.length + 1}`,
...danceData,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
const payload: any = {
name: danceData.name,
category: "dance",
description: danceData.description || "",
dance_attributes: {
choreographer: danceData.choreographer || "",
duration: danceData.duration || "",
difficulty: danceData.difficulty || "",
tutorial_video: danceData.videoUrl || "",
style: danceData.category || "",
},
}
if (danceData.coverUrl && !danceData.coverUrl.includes("placeholder")) {
payload.image_url = danceData.coverUrl
}
// 在实际应用中这里会调用API将数据保存到数据库
// 这里我们只是模拟添加到本地数组
mockDances.push(newDance)
return { data: newDance }
const response = await apiClient.post(`/card/templates/`, payload)
const data = response.data?.data || response.data
return { data: mapBackendDance(data) }
} catch (error) {
return handleApiError(error)
}
}
// 更新舞蹈
// 更新舞蹈(需要管理员权限)
export async function updateDance(id: string, danceData: Partial<Dance>) {
try {
// 模拟API请求延迟
await new Promise((resolve) => setTimeout(resolve, 600))
const payload: any = {}
if (danceData.name !== undefined) payload.name = danceData.name
if (danceData.description !== undefined) payload.description = danceData.description
const index = mockDances.findIndex((dance) => dance.id === id)
const attrs: any = {}
if (danceData.choreographer !== undefined) attrs.choreographer = danceData.choreographer
if (danceData.duration !== undefined) attrs.duration = danceData.duration
if (danceData.difficulty !== undefined) attrs.difficulty = danceData.difficulty
if (danceData.videoUrl !== undefined) attrs.tutorial_video = danceData.videoUrl
if (danceData.category !== undefined) attrs.style = danceData.category
if (Object.keys(attrs).length > 0) payload.dance_attributes = attrs
if (danceData.coverUrl !== undefined) payload.image_url = danceData.coverUrl
if (index === -1) {
throw new Error("舞蹈不存在")
}
// 更新舞蹈数据
const updatedDance = {
...mockDances[index],
...danceData,
updatedAt: new Date().toISOString(),
}
// 在实际应用中这里会调用API将更新保存到数据库
// 这里我们只是模拟更新本地数组
mockDances[index] = updatedDance
return { data: updatedDance }
const response = await apiClient.patch(`/card/templates/${id}/`, payload)
const data = response.data?.data || response.data
return { data: mapBackendDance(data) }
} catch (error) {
return handleApiError(error)
}
}
// 删除舞蹈
// 删除舞蹈(需要管理员权限)
export async function deleteDance(id: string) {
try {
// 模拟API请求延迟
await new Promise((resolve) => setTimeout(resolve, 500))
const index = mockDances.findIndex((dance) => dance.id === id)
if (index === -1) {
throw new Error("舞蹈不存在")
}
// 在实际应用中这里会调用API从数据库中删除
// 这里我们只是模拟从本地数组中删除
const deletedDance = mockDances.splice(index, 1)[0]
return { data: deletedDance }
await apiClient.delete(`/card/templates/${id}/`)
return { data: { id } }
} catch (error) {
return handleApiError(error)
}

View File

@ -207,6 +207,46 @@ export const replaceFood = async (id: string, foodData: Omit<Food, 'id' | 'creat
}
};
/**
*
* @param id ID
* @returns
*/
export const publishFood = async (id: string): Promise<ApiResponse<{ message: string }>> => {
try {
const response = await apiClient.post(`/food/foods/${id}/publish/`);
if (!response.data.success) {
throw new Error(response.data.message || '发布食物失败');
}
return response.data;
} catch (error) {
console.error("发布食物失败:", error);
throw error;
}
};
/**
*
* @param id ID
* @returns
*/
export const archiveFood = async (id: string): Promise<ApiResponse<{ message: string }>> => {
try {
const response = await apiClient.post(`/food/foods/${id}/archive/`);
if (!response.data.success) {
throw new Error(response.data.message || '归档食物失败');
}
return response.data;
} catch (error) {
console.error("归档食物失败:", error);
throw error;
}
};
/**
*
* @param id ID

View File

@ -1,188 +1,103 @@
import type { HomeDecor } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟家居装饰数据
const mockHomeDecors: HomeDecor[] = [
{
id: "DEC001",
name: "星空投影灯",
type: "灯饰",
rarity: "稀有",
description: "可以在房间内投影出美丽的星空,营造浪漫氛围。",
releaseDate: "2023-10-20",
status: "已发布",
activatedCount: 1342,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "DEC002",
name: "音乐主题壁纸",
type: "墙饰",
rarity: "普通",
description: "以音乐元素为主题的壁纸,适合洛天依的房间装饰。",
releaseDate: "2023-11-05",
status: "已发布",
activatedCount: 2156,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "DEC003",
name: "音符地毯",
type: "地饰",
rarity: "稀有",
description: "音符形状的地毯,踩上去会发出悦耳的音符声。",
releaseDate: "2023-12-15",
status: "已发布",
activatedCount: 987,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "DEC004",
name: "全息投影装置",
type: "科技装饰",
rarity: "传说",
description: "可以投影出洛天依的全息影像,实现虚拟互动。",
releaseDate: "2024-01-20",
status: "已发布",
activatedCount: 456,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "DEC005",
name: "樱花主题家具套装",
type: "家具套装",
rarity: "史诗",
description: "以樱花为主题的家具套装,包含床、桌椅、柜子等多件家具。",
releaseDate: "",
status: "未发布",
activatedCount: 0,
image: "/placeholder.svg?height=300&width=300",
},
]
function mapBackendHomeDecor(item: any): HomeDecor {
const attrs = item.attributes || {}
return {
id: String(item.id),
name: item.name,
description: item.description || "",
imageUrl: item.image_url || "",
rarity: item.rarity_display || item.rarity || "",
category: attrs.decoration_type || attrs.style || "",
status: item.status_display || item.status || "",
publishedAt: item.published_at || "",
batchesCount: item.batches_count || 0,
activeCardsCount: item.active_cards_count || 0,
tags: attrs.placement ? [attrs.placement] : [],
createdAt: item.created_at || "",
updatedAt: item.updated_at || "",
}
}
// 获取家居装饰列表
export const getHomeDecors = async (params?: PaginationParams): Promise<PaginatedResponse<HomeDecor>> => {
let filteredDecors = [...mockHomeDecors]
// 搜索过滤
if (params?.search) {
const search = params.search.toLowerCase()
filteredDecors = filteredDecors.filter(
(decor) =>
decor.name.toLowerCase().includes(search) ||
decor.id.toLowerCase().includes(search) ||
decor.type.toLowerCase().includes(search),
)
}
// 排序
if (params?.sortBy) {
filteredDecors.sort((a: any, b: any) => {
const aValue = a[params.sortBy!]
const bValue = b[params.sortBy!]
if (typeof aValue === "string" && typeof bValue === "string") {
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
}
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
})
}
// 分页
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize
const paginatedDecors = filteredDecors.slice(startIndex, startIndex + pageSize)
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
return mockResponse({
items: paginatedDecors,
total: filteredDecors.length,
const response = await apiClient.get(
`/card/category/decoration/?page=${page}&page_size=${pageSize}${searchParam}`
)
const data = response.data?.data || response.data
const results: any[] = data.results || data
const total = data.count || results.length
return {
items: results.map(mapBackendHomeDecor),
total,
page,
pageSize,
totalPages: Math.ceil(filteredDecors.length / pageSize),
})
totalPages: Math.ceil(total / pageSize),
}
}
// 获取单个家居装饰
export const getHomeDecor = async (id: string): Promise<HomeDecor> => {
const decor = mockHomeDecors.find((decor) => decor.id === id)
if (!decor) {
return mockResponse({} as HomeDecor, "家居装饰不存在")
const response = await apiClient.get(`/card/templates/${id}/`)
const data = response.data?.data || response.data
return mapBackendHomeDecor(data)
}
return mockResponse(decor)
}
// 创建家居装饰
export const createHomeDecor = async (decorData: Partial<HomeDecor>): Promise<HomeDecor> => {
// 生成新的家居装饰ID
const decorId = "DEC" + String(mockHomeDecors.length + 1).padStart(3, "0")
const newDecor: HomeDecor = {
id: decorId,
name: decorData.name || "",
type: decorData.type || "",
rarity: decorData.rarity || "",
export const createHomeDecor = async (decorData: Partial<HomeDecor> & { rarityValue?: string }): Promise<HomeDecor> => {
const payload: any = {
name: decorData.name,
category: "decoration",
description: decorData.description || "",
releaseDate: decorData.releaseDate || "",
status: decorData.status || "未发布",
activatedCount: 0,
image: decorData.image || "/placeholder.svg?height=300&width=300",
}
if (decorData.rarityValue) {
payload.rarity = decorData.rarityValue
}
if (decorData.imageUrl) {
payload.image_url = decorData.imageUrl
}
if (decorData.category) {
payload.decoration_attributes = {
decoration_type: decorData.category,
}
}
mockHomeDecors.push(newDecor)
return mockResponse(newDecor)
const response = await apiClient.post(`/card/templates/`, payload)
const data = response.data?.data || response.data
return mapBackendHomeDecor(data)
}
// 更新家居装饰
export const updateHomeDecor = async (id: string, decorData: Partial<HomeDecor>): Promise<HomeDecor> => {
const decorIndex = mockHomeDecors.findIndex((decor) => decor.id === id)
export const updateHomeDecor = async (id: string, decorData: Partial<HomeDecor> & { rarityValue?: string }): Promise<HomeDecor> => {
const payload: any = {}
if (decorData.name !== undefined) payload.name = decorData.name
if (decorData.description !== undefined) payload.description = decorData.description
if (decorData.category !== undefined) payload.decoration_attributes = { decoration_type: decorData.category }
if (decorData.rarityValue) payload.rarity = decorData.rarityValue
if (decorData.imageUrl !== undefined) payload.image_url = decorData.imageUrl
if (decorIndex === -1) {
return mockResponse({} as HomeDecor, "家居装饰不存在")
const response = await apiClient.patch(`/card/templates/${id}/`, payload)
const data = response.data?.data || response.data
return mapBackendHomeDecor(data)
}
const updatedDecor = {
...mockHomeDecors[decorIndex],
...decorData,
}
mockHomeDecors[decorIndex] = updatedDecor
return mockResponse(updatedDecor)
}
// 删除家居装饰
export const deleteHomeDecor = async (id: string): Promise<boolean> => {
const decorIndex = mockHomeDecors.findIndex((decor) => decor.id === id)
if (decorIndex === -1) {
return mockResponse(false, "家居装饰不存在")
await apiClient.delete(`/card/templates/${id}/`)
return true
}
// 已发布的家居装饰不能删除
if (mockHomeDecors[decorIndex].status === "已发布") {
return mockResponse(false, "已发布的家居装饰不能删除")
}
mockHomeDecors.splice(decorIndex, 1)
return mockResponse(true)
}
// 发布家居装饰
export const publishHomeDecor = async (id: string): Promise<HomeDecor> => {
const decorIndex = mockHomeDecors.findIndex((decor) => decor.id === id)
if (decorIndex === -1) {
return mockResponse({} as HomeDecor, "家居装饰不存在")
const response = await apiClient.post(`/card/templates/${id}/publish/`)
const data = response.data?.template || response.data
return mapBackendHomeDecor(data)
}
mockHomeDecors[decorIndex].status = "已发布"
mockHomeDecors[decorIndex].releaseDate = new Date().toISOString().split("T")[0]
return mockResponse(mockHomeDecors[decorIndex])
export const archiveHomeDecor = async (id: string): Promise<HomeDecor> => {
const response = await apiClient.post(`/card/templates/${id}/archive/`)
const data = response.data?.template || response.data
return mapBackendHomeDecor(data)
}

View File

@ -1,189 +1,103 @@
import type { Outfit } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟服装数据
const mockOutfits: Outfit[] = [
{
id: "OUT001",
name: "星空礼裙",
designer: "星辰设计工作室",
description: "以星空为灵感设计的华丽礼裙,适合正式场合穿着。",
releaseDate: "2023-08-15",
status: "已发布",
type: "礼服",
rarity: "传说",
image: "/placeholder.svg?height=300&width=300",
},
{
id: "OUT002",
name: "音符连衣裙",
designer: "音乐之声工作室",
description: "装饰有音符图案的连衣裙,轻盈飘逸,适合日常穿着。",
releaseDate: "2023-09-20",
status: "已发布",
type: "连衣裙",
rarity: "稀有",
image: "/placeholder.svg?height=300&width=300",
},
{
id: "OUT003",
name: "未来科技套装",
designer: "未来主义设计团队",
description: "融合未来科技元素的套装,包含上衣、裤子和配饰。",
releaseDate: "2023-10-30",
status: "已发布",
type: "套装",
rarity: "史诗",
image: "/placeholder.svg?height=300&width=300",
},
{
id: "OUT004",
name: "传统汉服",
designer: "古风设计工作室",
description: "传统风格的汉服,展现中国古典美学。",
releaseDate: "2023-12-05",
status: "已发布",
type: "汉服",
rarity: "稀有",
image: "/placeholder.svg?height=300&width=300",
},
{
id: "OUT005",
name: "春节限定旗袍",
designer: "洛天依官方设计团队",
description: "春节限定款旗袍,融合传统与现代元素。",
releaseDate: "",
status: "未发布",
type: "旗袍",
rarity: "传说",
image: "/placeholder.svg?height=300&width=300",
},
]
function mapBackendOutfit(item: any): Outfit {
const attrs = item.attributes || {}
return {
id: String(item.id),
name: item.name,
description: item.description || "",
imageUrl: item.image_url || "",
rarity: item.rarity_display || item.rarity || "",
category: attrs.style || item.card_type_display || "",
status: item.status_display || item.status || "",
publishedAt: item.published_at || "",
batchesCount: item.batches_count || 0,
activeCardsCount: item.active_cards_count || 0,
tags: attrs.season ? [attrs.season] : [],
createdAt: item.created_at || "",
updatedAt: item.updated_at || "",
}
}
// 获取服装列表
export const getOutfits = async (params?: PaginationParams): Promise<PaginatedResponse<Outfit>> => {
let filteredOutfits = [...mockOutfits]
// 搜索过滤
if (params?.search) {
const search = params.search.toLowerCase()
filteredOutfits = filteredOutfits.filter(
(outfit) =>
outfit.name.toLowerCase().includes(search) ||
outfit.id.toLowerCase().includes(search) ||
outfit.designer.toLowerCase().includes(search) ||
outfit.type.toLowerCase().includes(search),
)
}
// 排序
if (params?.sortBy) {
filteredOutfits.sort((a: any, b: any) => {
const aValue = a[params.sortBy!]
const bValue = b[params.sortBy!]
if (typeof aValue === "string" && typeof bValue === "string") {
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
}
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
})
}
// 分页
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize
const paginatedOutfits = filteredOutfits.slice(startIndex, startIndex + pageSize)
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
return mockResponse({
items: paginatedOutfits,
total: filteredOutfits.length,
const response = await apiClient.get(
`/card/category/clothing/?page=${page}&page_size=${pageSize}${searchParam}`
)
const data = response.data?.data || response.data
const results: any[] = data.results || data
const total = data.count || results.length
return {
items: results.map(mapBackendOutfit),
total,
page,
pageSize,
totalPages: Math.ceil(filteredOutfits.length / pageSize),
})
totalPages: Math.ceil(total / pageSize),
}
}
// 获取单个服装
export const getOutfit = async (id: string): Promise<Outfit> => {
const outfit = mockOutfits.find((outfit) => outfit.id === id)
if (!outfit) {
return mockResponse({} as Outfit, "服装不存在")
const response = await apiClient.get(`/card/templates/${id}/`)
const data = response.data?.data || response.data
return mapBackendOutfit(data)
}
return mockResponse(outfit)
}
// 创建服装
export const createOutfit = async (outfitData: Partial<Outfit>): Promise<Outfit> => {
// 生成新的服装ID
const outfitId = "OUT" + String(mockOutfits.length + 1).padStart(3, "0")
const newOutfit: Outfit = {
id: outfitId,
name: outfitData.name || "",
designer: outfitData.designer || "",
export const createOutfit = async (outfitData: Partial<Outfit> & { rarityValue?: string }): Promise<Outfit> => {
const payload: any = {
name: outfitData.name,
category: "clothing",
description: outfitData.description || "",
releaseDate: outfitData.releaseDate || "",
status: outfitData.status || "未发布",
type: outfitData.type || "",
rarity: outfitData.rarity || "",
image: outfitData.image || "/placeholder.svg?height=300&width=300",
}
if (outfitData.rarityValue) {
payload.rarity = outfitData.rarityValue
}
if (outfitData.imageUrl) {
payload.image_url = outfitData.imageUrl
}
if (outfitData.category) {
payload.clothing_attributes = {
style: outfitData.category,
}
}
mockOutfits.push(newOutfit)
return mockResponse(newOutfit)
const response = await apiClient.post(`/card/templates/`, payload)
const data = response.data?.data || response.data
return mapBackendOutfit(data)
}
// 更新服装
export const updateOutfit = async (id: string, outfitData: Partial<Outfit>): Promise<Outfit> => {
const outfitIndex = mockOutfits.findIndex((outfit) => outfit.id === id)
export const updateOutfit = async (id: string, outfitData: Partial<Outfit> & { rarityValue?: string }): Promise<Outfit> => {
const payload: any = {}
if (outfitData.name !== undefined) payload.name = outfitData.name
if (outfitData.description !== undefined) payload.description = outfitData.description
if (outfitData.category !== undefined) payload.clothing_attributes = { style: outfitData.category }
if (outfitData.rarityValue) payload.rarity = outfitData.rarityValue
if (outfitData.imageUrl !== undefined) payload.image_url = outfitData.imageUrl
if (outfitIndex === -1) {
return mockResponse({} as Outfit, "服装不存在")
const response = await apiClient.patch(`/card/templates/${id}/`, payload)
const data = response.data?.data || response.data
return mapBackendOutfit(data)
}
const updatedOutfit = {
...mockOutfits[outfitIndex],
...outfitData,
}
mockOutfits[outfitIndex] = updatedOutfit
return mockResponse(updatedOutfit)
}
// 删除服装
export const deleteOutfit = async (id: string): Promise<boolean> => {
const outfitIndex = mockOutfits.findIndex((outfit) => outfit.id === id)
if (outfitIndex === -1) {
return mockResponse(false, "服装不存在")
await apiClient.delete(`/card/templates/${id}/`)
return true
}
// 已发布的服装不能删除
if (mockOutfits[outfitIndex].status === "已发布") {
return mockResponse(false, "已发布的服装不能删除")
}
mockOutfits.splice(outfitIndex, 1)
return mockResponse(true)
}
// 发布服装
export const publishOutfit = async (id: string): Promise<Outfit> => {
const outfitIndex = mockOutfits.findIndex((outfit) => outfit.id === id)
if (outfitIndex === -1) {
return mockResponse({} as Outfit, "服装不存在")
const response = await apiClient.post(`/card/templates/${id}/publish/`)
const data = response.data?.template || response.data
return mapBackendOutfit(data)
}
mockOutfits[outfitIndex].status = "已发布"
mockOutfits[outfitIndex].releaseDate = new Date().toISOString().split("T")[0]
return mockResponse(mockOutfits[outfitIndex])
export const archiveOutfit = async (id: string): Promise<Outfit> => {
const response = await apiClient.post(`/card/templates/${id}/archive/`)
const data = response.data?.template || response.data
return mapBackendOutfit(data)
}

View File

@ -1,188 +1,104 @@
import type { Prop } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟道具数据
const mockProps: Prop[] = [
{
id: "PRP001",
name: "魔法麦克风",
type: "演出道具",
rarity: "稀有",
description: "洛天依的经典原创道具,可以增强歌声的魔力,让听众更加沉浸在音乐中。",
releaseDate: "2023-11-15",
status: "已发布",
activatedCount: 1245,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "PRP002",
name: "星光魔杖",
type: "互动道具",
rarity: "史诗",
description: "挥舞魔杖可以创造出美丽的星光效果,增加互动时的好感度。",
releaseDate: "2023-12-01",
status: "已发布",
activatedCount: 876,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "PRP003",
name: "音乐盒",
type: "收藏品",
rarity: "传说",
description: "精美的音乐盒,打开后会播放洛天依的经典歌曲,是珍贵的收藏品。",
releaseDate: "2024-01-10",
status: "已发布",
activatedCount: 532,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "PRP004",
name: "虚拟相机",
type: "互动道具",
rarity: "稀有",
description: "可以捕捉洛天依的精彩瞬间,保存为虚拟照片。",
releaseDate: "2024-02-05",
status: "已发布",
activatedCount: 967,
image: "/placeholder.svg?height=300&width=300",
},
{
id: "PRP005",
name: "节日礼盒",
type: "限定道具",
rarity: "史诗",
description: "节日限定礼盒,内含多种惊喜道具和装饰品。",
releaseDate: "",
status: "未发布",
activatedCount: 0,
image: "/placeholder.svg?height=300&width=300",
},
]
function mapBackendProp(item: any): Prop {
const attrs = item.attributes || {}
return {
id: String(item.id),
name: item.name,
description: item.description || "",
imageUrl: item.image_url || "/placeholder.svg?height=300&width=400",
rarity: item.rarity_display || item.rarity || "",
category: attrs.prop_type || "",
status: item.status_display || item.status || "",
publishedAt: item.published_at || "",
batchesCount: item.batches_count || 0,
activeCardsCount: item.active_cards_count || 0,
tags: attrs.material ? [attrs.material] : [],
createdAt: item.created_at || "",
updatedAt: item.updated_at || "",
}
}
// 获取道具列表
export const getProps = async (params?: PaginationParams): Promise<PaginatedResponse<Prop>> => {
let filteredProps = [...mockProps]
// 搜索过滤
if (params?.search) {
const search = params.search.toLowerCase()
filteredProps = filteredProps.filter(
(prop) =>
prop.name.toLowerCase().includes(search) ||
prop.id.toLowerCase().includes(search) ||
prop.type.toLowerCase().includes(search),
)
}
// 排序
if (params?.sortBy) {
filteredProps.sort((a: any, b: any) => {
const aValue = a[params.sortBy!]
const bValue = b[params.sortBy!]
if (typeof aValue === "string" && typeof bValue === "string") {
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
}
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
})
}
// 分页
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize
const paginatedProps = filteredProps.slice(startIndex, startIndex + pageSize)
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
return mockResponse({
items: paginatedProps,
total: filteredProps.length,
const response = await apiClient.get(
`/card/category/prop/?page=${page}&page_size=${pageSize}${searchParam}`
)
const data = response.data?.data || response.data
const results: any[] = data.results || data
const total = data.count || results.length
return {
items: results.map(mapBackendProp),
total,
page,
pageSize,
totalPages: Math.ceil(filteredProps.length / pageSize),
})
totalPages: Math.ceil(total / pageSize),
}
}
// 获取单个道具
export const getProp = async (id: string): Promise<Prop> => {
const prop = mockProps.find((prop) => prop.id === id)
if (!prop) {
return mockResponse({} as Prop, "道具不存在")
const response = await apiClient.get(`/card/templates/${id}/`)
const data = response.data?.data || response.data
return mapBackendProp(data)
}
return mockResponse(prop)
}
// 创建道具
export const createProp = async (propData: Partial<Prop>): Promise<Prop> => {
// 生成新的道具ID
const propId = "PRP" + String(mockProps.length + 1).padStart(3, "0")
const newProp: Prop = {
id: propId,
name: propData.name || "",
type: propData.type || "",
rarity: propData.rarity || "",
export const createProp = async (propData: Partial<Prop> & { rarityValue?: string }): Promise<Prop> => {
const payload: any = {
name: propData.name,
category: "prop",
description: propData.description || "",
releaseDate: propData.releaseDate || "",
status: propData.status || "未发布",
activatedCount: 0,
image: propData.image || "/placeholder.svg?height=300&width=300",
}
if (propData.rarityValue) {
payload.rarity = propData.rarityValue
}
if (propData.imageUrl) {
payload.image_url = propData.imageUrl
}
// 只在有实际属性值时才传 prop_attributes
if (propData.category) {
payload.prop_attributes = {
prop_type: propData.category,
}
}
mockProps.push(newProp)
return mockResponse(newProp)
const response = await apiClient.post(`/card/templates/`, payload)
const data = response.data?.data || response.data
return mapBackendProp(data)
}
// 更新道具
export const updateProp = async (id: string, propData: Partial<Prop>): Promise<Prop> => {
const propIndex = mockProps.findIndex((prop) => prop.id === id)
export const updateProp = async (id: string, propData: Partial<Prop> & { rarityValue?: string }): Promise<Prop> => {
const payload: any = {}
if (propData.name !== undefined) payload.name = propData.name
if (propData.description !== undefined) payload.description = propData.description
if (propData.category !== undefined) payload.prop_attributes = { prop_type: propData.category }
if (propData.rarityValue) payload.rarity = propData.rarityValue
if (propData.imageUrl !== undefined) payload.image_url = propData.imageUrl
if (propIndex === -1) {
return mockResponse({} as Prop, "道具不存在")
const response = await apiClient.patch(`/card/templates/${id}/`, payload)
const data = response.data?.data || response.data
return mapBackendProp(data)
}
const updatedProp = {
...mockProps[propIndex],
...propData,
}
mockProps[propIndex] = updatedProp
return mockResponse(updatedProp)
}
// 删除道具
export const deleteProp = async (id: string): Promise<boolean> => {
const propIndex = mockProps.findIndex((prop) => prop.id === id)
if (propIndex === -1) {
return mockResponse(false, "道具不存在")
await apiClient.delete(`/card/templates/${id}/`)
return true
}
// 已发布的道具不能删除
if (mockProps[propIndex].status === "已发布") {
return mockResponse(false, "已发布的道具不能删除")
}
mockProps.splice(propIndex, 1)
return mockResponse(true)
}
// 发布道具
export const publishProp = async (id: string): Promise<Prop> => {
const propIndex = mockProps.findIndex((prop) => prop.id === id)
if (propIndex === -1) {
return mockResponse({} as Prop, "道具不存在")
const response = await apiClient.post(`/card/templates/${id}/publish/`)
const data = response.data?.template || response.data
return mapBackendProp(data)
}
mockProps[propIndex].status = "已发布"
mockProps[propIndex].releaseDate = new Date().toISOString().split("T")[0]
return mockResponse(mockProps[propIndex])
export const archiveProp = async (id: string): Promise<Prop> => {
const response = await apiClient.post(`/card/templates/${id}/archive/`)
const data = response.data?.template || response.data
return mapBackendProp(data)
}

View File

@ -1,177 +1,70 @@
import type { Role } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟角色数据
const mockRoles: Role[] = [
{
id: "1",
name: "超级管理员",
description: "拥有系统所有权限",
permissions: ["all"],
userCount: 1,
createdAt: "2023-01-01",
updatedAt: "2023-01-01",
},
{
id: "2",
name: "内容管理员",
description: "管理系统内容,包括服装、道具、歌曲等",
permissions: ["content_view", "content_edit", "content_delete"],
userCount: 3,
createdAt: "2023-01-15",
updatedAt: "2023-03-20",
},
{
id: "3",
name: "AI模型管理员",
description: "管理AI模型的训练、部署和监控",
permissions: ["ai_model_view", "ai_model_edit", "ai_model_deploy"],
userCount: 2,
createdAt: "2023-02-10",
updatedAt: "2023-04-15",
},
{
id: "4",
name: "卡牌管理员",
description: "管理卡牌系统,包括服装卡、道具卡等",
permissions: ["card_view", "card_edit", "card_delete", "card_print"],
userCount: 4,
createdAt: "2023-03-05",
updatedAt: "2023-05-10",
},
{
id: "5",
name: "查看者",
description: "只有查看权限,无法编辑或删除内容",
permissions: ["dashboard_view", "content_view"],
userCount: 8,
createdAt: "2023-01-20",
updatedAt: "2023-01-20",
},
]
// 将后端 Django Group 数据映射到前端 Role 类型
function mapBackendGroup(g: any): Role {
return {
id: String(g.id),
name: g.name,
description: undefined,
permissions: g.permissions || [],
userCount: g.user_count || 0,
createdAt: undefined,
updatedAt: undefined,
}
}
// 获取角色列表
// 获取角色(Group)列表
export const getRoles = async (params?: PaginationParams): Promise<PaginatedResponse<Role>> => {
let filteredRoles = [...mockRoles]
// 搜索过滤
if (params?.search) {
const search = params.search.toLowerCase()
filteredRoles = filteredRoles.filter(
(role) =>
role.name.toLowerCase().includes(search) ||
(role.description && role.description.toLowerCase().includes(search)),
)
}
// 排序
if (params?.sortBy) {
filteredRoles.sort((a: any, b: any) => {
const aValue = a[params.sortBy!]
const bValue = b[params.sortBy!]
if (typeof aValue === "string" && typeof bValue === "string") {
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
}
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
})
}
// 分页
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize
const paginatedRoles = filteredRoles.slice(startIndex, startIndex + pageSize)
return mockResponse({
items: paginatedRoles,
total: filteredRoles.length,
const response = await apiClient.get(`/user/groups/?page=${page}&page_size=${pageSize}`)
const data = response.data?.data || response.data
const results: any[] = data.results || (Array.isArray(data) ? data : [])
const total = data.count || results.length
return {
items: results.map(mapBackendGroup),
total,
page,
pageSize,
totalPages: Math.ceil(filteredRoles.length / pageSize),
})
totalPages: Math.ceil(total / pageSize),
}
}
// 获取单个角色
export const getRole = async (id: string): Promise<Role> => {
const role = mockRoles.find((role) => role.id === id)
if (!role) {
return mockResponse({} as Role, "角色不存在")
const response = await apiClient.get(`/user/groups/${id}/`)
const data = response.data?.data || response.data
return mapBackendGroup(data)
}
return mockResponse(role)
}
// 创建角色
// 创建角色(需要管理员权限)
export const createRole = async (roleData: Partial<Role>): Promise<Role> => {
// 检查角色名是否已存在
if (mockRoles.some((role) => role.name === roleData.name)) {
return mockResponse({} as Role, "该角色名已存在")
const payload = {
name: roleData.name,
}
const newRole: Role = {
id: String(mockRoles.length + 1),
name: roleData.name || "",
description: roleData.description,
permissions: roleData.permissions || [],
userCount: 0,
createdAt: new Date().toISOString().split("T")[0],
updatedAt: new Date().toISOString().split("T")[0],
const response = await apiClient.post(`/user/groups/`, payload)
const data = response.data?.data || response.data
return mapBackendGroup(data)
}
mockRoles.push(newRole)
return mockResponse(newRole)
}
// 更新角色
// 更新角色(需要管理员权限)
export const updateRole = async (id: string, roleData: Partial<Role>): Promise<Role> => {
const roleIndex = mockRoles.findIndex((role) => role.id === id)
const payload: any = {}
if (roleData.name !== undefined) payload.name = roleData.name
if (roleIndex === -1) {
return mockResponse({} as Role, "角色不存在")
const response = await apiClient.patch(`/user/groups/${id}/`, payload)
const data = response.data?.data || response.data
return mapBackendGroup(data)
}
// 检查角色名是否已被其他角色使用
if (roleData.name && roleData.name !== mockRoles[roleIndex].name) {
const nameExists = mockRoles.some((role) => role.name === roleData.name && role.id !== id)
if (nameExists) {
return mockResponse({} as Role, "该角色名已被其他角色使用")
}
}
const updatedRole = {
...mockRoles[roleIndex],
...roleData,
updatedAt: new Date().toISOString().split("T")[0],
}
mockRoles[roleIndex] = updatedRole
return mockResponse(updatedRole)
}
// 删除角色
// 删除角色(需要管理员权限)
export const deleteRole = async (id: string): Promise<boolean> => {
const roleIndex = mockRoles.findIndex((role) => role.id === id)
if (roleIndex === -1) {
return mockResponse(false, "角色不存在")
}
// 超级管理员不能删除
if (mockRoles[roleIndex].name === "超级管理员") {
return mockResponse(false, "不能删除超级管理员角色")
}
// 有用户使用的角色不能删除
if (mockRoles[roleIndex].userCount && mockRoles[roleIndex].userCount > 0) {
return mockResponse(false, "该角色下有用户,不能删除")
}
mockRoles.splice(roleIndex, 1)
return mockResponse(true)
await apiClient.delete(`/user/groups/${id}/`)
return true
}

View File

@ -64,6 +64,10 @@ export interface Outfit {
imageUrl?: string
rarity?: string
category?: string
status?: string
publishedAt?: string
batchesCount?: number
activeCardsCount?: number
tags?: string[]
createdAt?: string
updatedAt?: string
@ -77,6 +81,10 @@ export interface Prop {
imageUrl?: string
rarity?: string
category?: string
status?: string
publishedAt?: string
batchesCount?: number
activeCardsCount?: number
tags?: string[]
createdAt?: string
updatedAt?: string
@ -244,7 +252,12 @@ export interface HomeDecor {
name: string
description?: string
imageUrl?: string
rarity?: string
category?: string
status?: string
publishedAt?: string
batchesCount?: number
activeCardsCount?: number
tags?: string[]
createdAt?: string
updatedAt?: string

View File

@ -34,18 +34,18 @@ export const uploadFile = async (
const response = await apiClient.post('/common/upload/', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Content-Type': undefined as any, // Let axios/browser set multipart boundary automatically
},
onUploadProgress: (progressEvent) => {
const { loaded, total } = progressEvent;
const percentage = total ? Math.round((loaded * 100) / total) : 0;
console.log(`📊 上传进度: ${percentage}% (${(loaded / 1024 / 1024).toFixed(2)}MB / ${(total / 1024 / 1024).toFixed(2)}MB)`);
console.log(`📊 上传进度: ${percentage}% (${(loaded / 1024 / 1024).toFixed(2)}MB / ${((total || 0) / 1024 / 1024).toFixed(2)}MB)`);
if (onProgress) {
onProgress({
loaded,
total,
total: total || 0,
percentage,
});
}
@ -84,7 +84,7 @@ export const uploadFiles = async (
const response = await apiClient.post('/common/upload/', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Content-Type': undefined as any, // Let axios/browser set multipart boundary automatically
},
onUploadProgress: (progressEvent) => {
const { loaded, total } = progressEvent;
@ -95,7 +95,7 @@ export const uploadFiles = async (
if (onProgress) {
onProgress({
loaded,
total,
total: total || 0,
percentage,
});
}

View File

@ -1,225 +1,100 @@
import type { User, LoginHistory } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟用户数据
const mockUsers: User[] = [
{
id: "1",
name: "管理员",
email: "admin@example.com",
role: "超级管理员",
status: "活跃",
registeredAt: "2023-01-01",
lastLogin: "今天 08:45",
phone: "13800138000",
address: "北京市海淀区中关村",
permissions: [
"仪表盘查看",
"用户管理",
"角色权限管理",
"AI模型管理",
"服装管理",
"道具管理",
"歌曲管理",
"系统设置",
],
loginHistory: [
{ date: "2023-05-15 08:45", ip: "192.168.1.1", device: "Chrome / Windows" },
{ date: "2023-05-14 15:30", ip: "192.168.1.1", device: "Chrome / Windows" },
{ date: "2023-05-13 09:20", ip: "192.168.1.1", device: "Chrome / Windows" },
],
},
{
id: "2",
name: "张三",
email: "zhangsan@example.com",
role: "内容管理员",
status: "活跃",
registeredAt: "2023-03-15",
lastLogin: "昨天 16:30",
phone: "13900139000",
permissions: ["仪表盘查看", "服装管理", "道具管理", "歌曲管理"],
loginHistory: [
{ date: "2023-05-14 16:30", ip: "192.168.1.2", device: "Safari / macOS" },
{ date: "2023-05-13 14:20", ip: "192.168.1.2", device: "Safari / macOS" },
],
},
{
id: "3",
name: "李四",
email: "lisi@example.com",
role: "AI模型管理员",
status: "活跃",
registeredAt: "2023-05-20",
lastLogin: "3天前",
permissions: ["仪表盘查看", "AI模型管理"],
loginHistory: [{ date: "2023-05-12 10:15", ip: "192.168.1.3", device: "Firefox / Ubuntu" }],
},
{
id: "4",
name: "王五",
email: "wangwu@example.com",
role: "卡牌管理员",
status: "活跃",
registeredAt: "2023-07-10",
lastLogin: "1周前",
permissions: ["仪表盘查看", "服装管理", "道具管理", "家居装饰管理"],
},
{
id: "5",
name: "赵六",
email: "zhaoliu@example.com",
role: "查看者",
status: "未激活",
registeredAt: "2023-09-05",
permissions: ["仪表盘查看"],
},
]
// 将后端 ParadiseUser 数据映射到前端 User 类型
function mapBackendUser(u: any): User {
return {
id: String(u.id),
name: u.username || u.email || u.phone_number || "",
email: u.email || "",
role: u.is_superuser ? "超级管理员" : u.is_staff ? "管理员" : "普通用户",
status: u.is_active ? "活跃" : "未激活",
registeredAt: u.date_joined ? u.date_joined.split("T")[0] : "",
lastLogin: u.last_login ? u.last_login.split("T")[0] : undefined,
phone: u.phone_number || undefined,
address: u.resident_city || undefined,
avatar: undefined,
permissions: [],
loginHistory: [],
}
}
// 获取用户列表
export const getUsers = async (params?: PaginationParams): Promise<PaginatedResponse<User>> => {
let filteredUsers = [...mockUsers]
// 搜索过滤
if (params?.search) {
const search = params.search.toLowerCase()
filteredUsers = filteredUsers.filter(
(user) =>
user.name.toLowerCase().includes(search) ||
user.email.toLowerCase().includes(search) ||
user.role.toLowerCase().includes(search),
)
}
// 排序
if (params?.sortBy) {
filteredUsers.sort((a: any, b: any) => {
const aValue = a[params.sortBy!]
const bValue = b[params.sortBy!]
if (typeof aValue === "string" && typeof bValue === "string") {
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
}
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
})
}
// 分页
const page = params?.page || 1
const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize
const paginatedUsers = filteredUsers.slice(startIndex, startIndex + pageSize)
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
return mockResponse({
items: paginatedUsers,
total: filteredUsers.length,
const response = await apiClient.get(`/user/?page=${page}&page_size=${pageSize}${searchParam}`)
const data = response.data
// 后端直接返回分页数据DRF 默认格式)
const results: any[] = data.results || data
const total = data.count || results.length
return {
items: results.map(mapBackendUser),
total,
page,
pageSize,
totalPages: Math.ceil(filteredUsers.length / pageSize),
})
totalPages: Math.ceil(total / pageSize),
}
}
// 获取单个用户
export const getUser = async (id: string): Promise<User> => {
const user = mockUsers.find((user) => user.id === id)
if (!user) {
return mockResponse({} as User, "用户不存在")
}
return mockResponse(user)
const response = await apiClient.get(`/user/${id}/`)
return mapBackendUser(response.data)
}
// 创建用户
export const createUser = async (userData: Partial<User>): Promise<User> => {
// 检查邮箱是否已存在
if (mockUsers.some((user) => user.email === userData.email)) {
return mockResponse({} as User, "该邮箱已被注册")
export const createUser = async (userData: Partial<User> & { password?: string }): Promise<User> => {
const payload: any = {
username: userData.name,
email: userData.email,
phone_number: userData.phone,
password1: userData.password || "changeme123",
password2: userData.password || "changeme123",
is_active: userData.status === "活跃",
is_staff: userData.role === "管理员" || userData.role === "超级管理员",
}
const newUser: User = {
id: String(mockUsers.length + 1),
name: userData.name || "",
email: userData.email || "",
role: userData.role || "查看者",
status: userData.status || "未激活",
registeredAt: new Date().toISOString().split("T")[0],
phone: userData.phone,
address: userData.address,
permissions: [],
loginHistory: [],
}
mockUsers.push(newUser)
return mockResponse(newUser)
const response = await apiClient.post(`/user/`, payload)
return mapBackendUser(response.data)
}
// 更新用户
export const updateUser = async (id: string, userData: Partial<User>): Promise<User> => {
const userIndex = mockUsers.findIndex((user) => user.id === id)
if (userIndex === -1) {
return mockResponse({} as User, "用户不存在")
const payload: any = {}
if (userData.name !== undefined) payload.username = userData.name
if (userData.email !== undefined) payload.email = userData.email
if (userData.phone !== undefined) payload.phone_number = userData.phone
if (userData.address !== undefined) payload.resident_city = userData.address
if (userData.status !== undefined) payload.is_active = userData.status === "活跃"
if (userData.role !== undefined) {
payload.is_staff = userData.role === "管理员" || userData.role === "超级管理员"
payload.is_superuser = userData.role === "超级管理员"
}
// 检查邮箱是否已被其他用户使用
if (userData.email && userData.email !== mockUsers[userIndex].email) {
const emailExists = mockUsers.some((user) => user.email === userData.email && user.id !== id)
if (emailExists) {
return mockResponse({} as User, "该邮箱已被其他用户使用")
}
}
const updatedUser = {
...mockUsers[userIndex],
...userData,
}
mockUsers[userIndex] = updatedUser
return mockResponse(updatedUser)
const response = await apiClient.patch(`/user/${id}/`, payload)
return mapBackendUser(response.data)
}
// 删除用户
export const deleteUser = async (id: string): Promise<boolean> => {
const userIndex = mockUsers.findIndex((user) => user.id === id)
if (userIndex === -1) {
return mockResponse(false, "用户不存在")
}
// 超级管理员不能删除
if (mockUsers[userIndex].role === "超级管理员") {
return mockResponse(false, "不能删除超级管理员")
}
mockUsers.splice(userIndex, 1)
return mockResponse(true)
}
// 获取用户登录历史
export const getUserLoginHistory = async (id: string): Promise<LoginHistory[]> => {
const user = mockUsers.find((user) => user.id === id)
if (!user) {
return mockResponse([], "用户不存在")
}
return mockResponse(user.loginHistory || [])
await apiClient.delete(`/user/${id}/`)
return true
}
// 更改用户状态
export const changeUserStatus = async (id: string, status: User["status"]): Promise<User> => {
const userIndex = mockUsers.findIndex((user) => user.id === id)
if (userIndex === -1) {
return mockResponse({} as User, "用户不存在")
const response = await apiClient.patch(`/user/${id}/`, {
is_active: status === "活跃",
})
return mapBackendUser(response.data)
}
mockUsers[userIndex].status = status
return mockResponse(mockUsers[userIndex])
// 获取用户登录历史(后端暂无此接口,返回空数组)
export const getUserLoginHistory = async (_id: string): Promise<LoginHistory[]> => {
return []
}

View File

@ -0,0 +1,122 @@
/**
* - 访
*
*
* | | | | AI模型管理员 | | |
* |-------------|-----------|-----------|------------|-----------|-------|
* | | | | | | |
* | | | | | | |
* | | | | | | |
* | AI模型管理 | | | | | |
* | | | | | | |
* | | | | | | |
* | | | | | | |
* | | | | | | |
*/
// 所有可识别的角色名称
export type RoleName = "超级管理员" | "内容管理员" | "AI模型管理员" | "卡牌管理员" | "查看者" | "管理员";
// 模块权限 key
export type PermissionModule =
| "dashboard"
| "users"
| "permissions"
| "ai-model"
| "outfits"
| "props"
| "home-decor"
| "food"
| "songs"
| "dances"
| "achievements"
| "affinity"
| "settings";
// 权限矩阵定义
const PERMISSION_MATRIX: Record<RoleName, PermissionModule[]> = {
: [
"dashboard", "users", "permissions", "ai-model",
"outfits", "props", "home-decor", "food",
"songs", "dances", "achievements", "affinity", "settings",
],
: [
"dashboard", "outfits", "props", "home-decor", "food",
"songs", "dances", "achievements", "affinity",
],
AI模型管理员: [
"dashboard", "ai-model",
],
: [
"dashboard", "outfits", "props", "home-decor", "food",
],
: [
"dashboard",
],
// 后备角色:普通管理员等同于查看者
: [
"dashboard",
],
};
/**
*
*/
export function getUserRole(): RoleName {
if (typeof window === "undefined") return "查看者";
const role = localStorage.getItem("user_role");
if (role && role in PERMISSION_MATRIX) {
return role as RoleName;
}
return "查看者";
}
/**
*
*/
export function getAllowedModules(): PermissionModule[] {
const role = getUserRole();
return PERMISSION_MATRIX[role] || PERMISSION_MATRIX["查看者"];
}
/**
*
*/
export function hasPermission(module: PermissionModule): boolean {
return getAllowedModules().includes(module);
}
/**
*
*/
export function getModuleFromPath(pathname: string): PermissionModule | null {
// 去掉开头的斜杠,取第一段路径
const segment = pathname.replace(/^\//, "").split("/")[0];
const pathMap: Record<string, PermissionModule> = {
"": "dashboard",
"ai-model": "ai-model",
"outfits": "outfits",
"props": "props",
"home-decor": "home-decor",
"food": "food",
"songs": "songs",
"dances": "dances",
"achievements": "achievements",
"affinity": "affinity",
"users": "users",
"permissions": "permissions",
"settings": "settings",
};
return pathMap[segment] ?? null;
}
/**
* 访
*/
export function hasPathPermission(pathname: string): boolean {
const module = getModuleFromPath(pathname);
if (module === null) return true; // 未知路径默认允许(如 login、register
return hasPermission(module);
}

View File

@ -238,9 +238,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -257,9 +254,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -276,9 +270,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -295,9 +286,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -314,9 +302,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -333,9 +318,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -352,9 +334,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -377,9 +356,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -402,9 +378,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -427,9 +400,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -452,9 +422,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -477,9 +444,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [

View File

@ -48,17 +48,10 @@
dependencies:
"@standard-schema/utils" "^0.3.0"
"@img/sharp-darwin-arm64@0.33.5":
"@img/sharp-win32-x64@0.33.5":
version "0.33.5"
resolved "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz"
integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==
optionalDependencies:
"@img/sharp-libvips-darwin-arm64" "1.0.4"
"@img/sharp-libvips-darwin-arm64@1.0.4":
version "1.0.4"
resolved "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz"
integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==
resolved "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz"
integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==
"@isaacs/cliui@^8.0.2":
version "8.0.2"
@ -109,10 +102,10 @@
resolved "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz"
integrity sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==
"@next/swc-darwin-arm64@15.2.4":
"@next/swc-win32-x64-msvc@15.2.4":
version "15.2.4"
resolved "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz"
integrity sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==
resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz"
integrity sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"

View File

@ -15,13 +15,21 @@ from .serializers import (
UserAchievementListSerializer
)
class AchievementViewSet(viewsets.ReadOnlyModelViewSet):
class IsAdminOrReadOnly(permissions.BasePermission):
"""管理员可写,其他人只读"""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return request.user and request.user.is_authenticated
return request.user and request.user.is_staff
class AchievementViewSet(viewsets.ModelViewSet):
"""
成就相关视图集
提供成就的只读操作
管理员可增删改查普通用户只读
"""
serializer_class = AchievementSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAdminOrReadOnly]
authentication_classes = [RedisTokenAuthentication]
def get_queryset(self):

View File

@ -1,7 +1,12 @@
from django.urls import path
from .views import ChatBotAPIView, MultiChatAPIView
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ChatBotAPIView, MultiChatAPIView, BotViewSet
router = DefaultRouter()
router.register(r'bots', BotViewSet, basename='bot')
urlpatterns = [
path('', include(router.urls)),
path('chat/<int:bot_id>/', ChatBotAPIView.as_view(), name='chat-bot'),
path('multichat/', MultiChatAPIView.as_view(), name='multi-chat'),
]

View File

@ -1,6 +1,6 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework import status, viewsets, permissions
from django.contrib.auth.models import User
from .models import ChatMessage, Bot
from userapp.models import ParadiseUser
@ -16,6 +16,30 @@ import requests
import re
logger = logging.getLogger(__name__)
class BotSerializer(serializers.ModelSerializer):
class Meta:
model = Bot
fields = ['id', 'name', 'description']
class IsAdminOrReadOnly(permissions.BasePermission):
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return request.user and request.user.is_authenticated
return request.user and request.user.is_staff
class BotViewSet(viewsets.ModelViewSet):
"""
机器人(AI模型)管理接口
管理员可增删改查普通用户只读
"""
queryset = Bot.objects.all()
serializer_class = BotSerializer
permission_classes = [IsAdminOrReadOnly]
authentication_classes = [RedisTokenAuthentication]
from .kimi import KIMI
from .audio.AudioService import get_audio_service

View File

@ -0,0 +1,33 @@
# Generated by Django 5.2.12 on 2026-03-19 06:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('card', '0009_alter_songattributes_bpm'),
]
operations = [
migrations.AlterField(
model_name='propattributes',
name='durability',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='耐久度'),
),
migrations.AlterField(
model_name='propattributes',
name='material',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='材质'),
),
migrations.AlterField(
model_name='propattributes',
name='prop_type',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='道具类型'),
),
migrations.AlterField(
model_name='propattributes',
name='size',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='尺寸'),
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 5.2.12 on 2026-03-19 09:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('card', '0010_prop_attributes_optional_fields'),
]
operations = [
migrations.AlterField(
model_name='cardtemplate',
name='image',
field=models.URLField(blank=True, default='', max_length=500, verbose_name='展示图片'),
),
migrations.AlterField(
model_name='clothingattributes',
name='care_instructions',
field=models.TextField(blank=True, default='', verbose_name='保养说明'),
),
migrations.AlterField(
model_name='clothingattributes',
name='color',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='颜色'),
),
migrations.AlterField(
model_name='clothingattributes',
name='fit_type',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='版型'),
),
migrations.AlterField(
model_name='clothingattributes',
name='material',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='材质'),
),
migrations.AlterField(
model_name='clothingattributes',
name='season',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='季节'),
),
migrations.AlterField(
model_name='clothingattributes',
name='size',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='尺码'),
),
migrations.AlterField(
model_name='clothingattributes',
name='style',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='风格'),
),
]

View File

@ -0,0 +1,73 @@
# Generated by Django 5.2.12 on 2026-03-19 09:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('card', '0011_clothing_attributes_optional_fields'),
]
operations = [
migrations.AlterField(
model_name='decorationattributes',
name='care_instructions',
field=models.TextField(blank=True, default='', verbose_name='保养说明'),
),
migrations.AlterField(
model_name='decorationattributes',
name='decoration_type',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='装饰类型'),
),
migrations.AlterField(
model_name='decorationattributes',
name='indoor_outdoor',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='室内/室外'),
),
migrations.AlterField(
model_name='decorationattributes',
name='material',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='材质'),
),
migrations.AlterField(
model_name='decorationattributes',
name='placement',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='摆放位置'),
),
migrations.AlterField(
model_name='decorationattributes',
name='size',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='尺寸'),
),
migrations.AlterField(
model_name='decorationattributes',
name='style',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='风格'),
),
migrations.AlterField(
model_name='furnitureattributes',
name='care_instructions',
field=models.TextField(blank=True, default='', verbose_name='保养说明'),
),
migrations.AlterField(
model_name='furnitureattributes',
name='dimensions',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='尺寸'),
),
migrations.AlterField(
model_name='furnitureattributes',
name='furniture_type',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='家具类型'),
),
migrations.AlterField(
model_name='furnitureattributes',
name='material',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='材质'),
),
migrations.AlterField(
model_name='furnitureattributes',
name='style',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='风格'),
),
]

View File

@ -53,13 +53,7 @@ class CardTemplate(models.Model):
rarity = models.CharField('稀有度', max_length=20, choices=RARITY_CHOICES, default='common')
# Display image
image = models.ImageField(
'展示图片',
upload_to='templates/images/',
storage=oss_storage,
blank=True,
null=True
)
image = models.URLField('展示图片', max_length=500, blank=True, default='')
# 3D model information
model_url = models.URLField('模型包链接', max_length=255, blank=True, null=True)
@ -267,13 +261,13 @@ class CardUsageLog(models.Model):
class ClothingAttributes(models.Model):
"""服装卡牌的特殊属性"""
template = models.OneToOneField(CardTemplate, verbose_name='卡片模板', on_delete=models.CASCADE, related_name='clothing_attrs')
style = models.CharField('风格', max_length=50)
size = models.CharField('尺码', max_length=20)
color = models.CharField('颜色', max_length=50)
season = models.CharField('季节', max_length=20)
material = models.CharField('材质', max_length=100)
fit_type = models.CharField('版型', max_length=50)
care_instructions = models.TextField('保养说明', blank=True)
style = models.CharField('风格', max_length=50, blank=True, default='')
size = models.CharField('尺码', max_length=20, blank=True, default='')
color = models.CharField('颜色', max_length=50, blank=True, default='')
season = models.CharField('季节', max_length=20, blank=True, default='')
material = models.CharField('材质', max_length=100, blank=True, default='')
fit_type = models.CharField('版型', max_length=50, blank=True, default='')
care_instructions = models.TextField('保养说明', blank=True, default='')
class Meta:
verbose_name = '服装属性'
@ -282,11 +276,11 @@ class ClothingAttributes(models.Model):
class PropAttributes(models.Model):
"""道具卡牌的特殊属性"""
template = models.OneToOneField(CardTemplate, verbose_name='卡片模板', on_delete=models.CASCADE, related_name='prop_attrs')
prop_type = models.CharField('道具类型', max_length=50)
material = models.CharField('材质', max_length=100)
size = models.CharField('尺寸', max_length=50)
prop_type = models.CharField('道具类型', max_length=50, blank=True, default='')
material = models.CharField('材质', max_length=100, blank=True, default='')
size = models.CharField('尺寸', max_length=50, blank=True, default='')
weight = models.FloatField('重量(g)', null=True, blank=True)
durability = models.CharField('耐久度', max_length=50)
durability = models.CharField('耐久度', max_length=50, blank=True, default='')
usage_instructions = models.TextField('使用说明', blank=True)
class Meta:
@ -327,14 +321,14 @@ class DanceAttributes(models.Model):
class FurnitureAttributes(models.Model):
"""家具卡牌的特殊属性"""
template = models.OneToOneField(CardTemplate, verbose_name='卡片模板', on_delete=models.CASCADE, related_name='furniture_attrs')
furniture_type = models.CharField('家具类型', max_length=50)
style = models.CharField('风格', max_length=50)
material = models.CharField('材质', max_length=100)
dimensions = models.CharField('尺寸', max_length=100)
furniture_type = models.CharField('家具类型', max_length=50, blank=True, default='')
style = models.CharField('风格', max_length=50, blank=True, default='')
material = models.CharField('材质', max_length=100, blank=True, default='')
dimensions = models.CharField('尺寸', max_length=100, blank=True, default='')
weight = models.FloatField('重量(kg)', null=True, blank=True)
assembly_required = models.BooleanField('需要组装', default=False)
max_weight_capacity = models.FloatField('最大承重(kg)', null=True, blank=True)
care_instructions = models.TextField('保养说明', blank=True)
care_instructions = models.TextField('保养说明', blank=True, default='')
class Meta:
verbose_name = '家具属性'
@ -343,14 +337,14 @@ class FurnitureAttributes(models.Model):
class DecorationAttributes(models.Model):
"""装饰卡牌的特殊属性"""
template = models.OneToOneField(CardTemplate, verbose_name='卡片模板', on_delete=models.CASCADE, related_name='decoration_attrs')
decoration_type = models.CharField('装饰类型', max_length=50)
style = models.CharField('风格', max_length=50)
material = models.CharField('材质', max_length=100)
size = models.CharField('尺寸', max_length=50)
placement = models.CharField('摆放位置', max_length=50)
indoor_outdoor = models.CharField('室内/室外', max_length=20)
decoration_type = models.CharField('装饰类型', max_length=50, blank=True, default='')
style = models.CharField('风格', max_length=50, blank=True, default='')
material = models.CharField('材质', max_length=100, blank=True, default='')
size = models.CharField('尺寸', max_length=50, blank=True, default='')
placement = models.CharField('摆放位置', max_length=50, blank=True, default='')
indoor_outdoor = models.CharField('室内/室外', max_length=20, blank=True, default='')
installation_required = models.BooleanField('需要安装', default=False)
care_instructions = models.TextField('保养说明', blank=True)
care_instructions = models.TextField('保养说明', blank=True, default='')
class Meta:
verbose_name = '装饰属性'

View File

@ -65,7 +65,7 @@ class CardTemplateSerializer(serializers.ModelSerializer):
rarity_display = serializers.SerializerMethodField()
card_type_display = serializers.SerializerMethodField()
status_display = serializers.SerializerMethodField()
image_url = serializers.SerializerMethodField()
image_url = serializers.URLField(source='image', required=False, allow_blank=True)
# 新增所有类别专有属性的write_only字段
clothing_attributes = ClothingAttributesSerializer(write_only=True, required=False)
@ -86,10 +86,11 @@ class CardTemplateSerializer(serializers.ModelSerializer):
# 新增
'clothing_attributes', 'prop_attributes', 'song_attributes', 'dance_attributes', 'furniture_attributes', 'decoration_attributes',
]
read_only_fields = ['created_at', 'updated_at', 'published_at', 'image_url']
read_only_fields = ['created_at', 'updated_at', 'published_at']
def create(self, validated_data):
# 把所有 *_attributes 字段pop掉只留主表字段
# 属性的创建由 View 层的 create 方法负责
validated_data.pop('clothing_attributes', None)
validated_data.pop('prop_attributes', None)
validated_data.pop('song_attributes', None)
@ -110,11 +111,6 @@ class CardTemplateSerializer(serializers.ModelSerializer):
def get_status_display(self, obj):
return obj.get_status_display()
def get_image_url(self, obj):
if obj.image:
return obj.image.url
return None
def to_representation(self, instance):
representation = super().to_representation(instance)
# 添加类别专有属性
@ -247,9 +243,9 @@ class CardSerializer(serializers.ModelSerializer):
def get_image_url(self, obj):
if obj.image:
return obj.image.url
# 如果卡片没有图片,使用模板的图片
# 如果卡片没有图片,使用模板的图片模板image现在是URLField字符串
elif obj.template and obj.template.image:
return obj.template.image.url
return obj.template.image
return None

View File

@ -8,6 +8,8 @@ from .oss import OSSUploader
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
import logging
import traceback
import os
from userapp.authentication import RedisTokenAuthentication
logger = logging.getLogger(__name__)
@ -70,22 +72,42 @@ def upload_file(request):
try:
# 检查是否有文件
if 'file' not in request.FILES:
print("[Upload] No file in request.FILES, keys:", list(request.FILES.keys()))
return Response({"error": "No file provided"}, status=status.HTTP_400_BAD_REQUEST)
file_obj = request.FILES['file']
original_name = file_obj.name
file_size = file_obj.size
content_type = getattr(file_obj, 'content_type', 'unknown')
print(f"[Upload] File received: {original_name}, size={file_size}, type={content_type}")
# 获取参数
is_permanent = request.data.get('is_permanent', 'false').lower() == 'true'
is_permanent_raw = request.data.get('is_permanent', 'false')
is_permanent = str(is_permanent_raw).lower() == 'true'
filename = request.data.get('filename', None)
# 上传文件
uploader = OSSUploader()
result = uploader.upload_file(file_obj, is_permanent, filename)
return Response(result, status=status.HTTP_200_OK)
# 返回包含前端需要的字段
_, ext = os.path.splitext(original_name)
response_data = {
'url': result['url'],
'key': result['key'],
'is_permanent': result['is_permanent'],
'filename': original_name,
'size': file_size,
'mimeType': content_type,
}
print(f"[Upload] Success: {result['url']}")
return Response(response_data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"File upload failed: {str(e)}")
error_detail = traceback.format_exc()
print(f"[Upload] ERROR: {error_detail}")
logger.error(f"File upload failed: {str(e)}\n{error_detail}")
return Response(
{"error": "File upload failed", "detail": str(e)},
status=status.HTTP_400_BAD_REQUEST

View File

@ -0,0 +1,683 @@
# 服务器端修改指南 — 设备动态绑定
> 本文档对应手机端文档 `LTY_App_Project_URP/docs/修改指南_手机端.md`
> 和设备端文档 `LTY_Project/docs/修改指南_设备端.md`
> 三个文档可以交叉验证。
>
> **注意**:手机端文档新增了**步骤 0手机号登录功能**作为前置基础,
> 后续步骤编号与本文档/设备端文档不完全对齐,请以各文档内的交叉引用为准。
---
## 步骤 1修改 MAC 登录接口,支持返回绑定用户信息
### 目标
当前 `MacAddressLoginView` 已经在做正确的事:查找设备 → 检查绑定 → 返回绑定用户的 token。但需要增强返回信息让设备端能区分"未绑定"和其他错误,并返回更多上下文。
### 修改文件
`userapp/views.py``MacAddressLoginView`
### 当前代码(第 99-142 行)
```python
def post(self, request):
mac_address = request.data.get('mac_address')
if not mac_address:
logger.warning("Attempt to login without MAC address")
return error_response(message="MAC address is required")
logger.info(f"Attempting MAC address login for device: {mac_address}")
try:
device = Device.objects.get(mac_address=mac_address)
if not device.is_active:
logger.warning(f"Device not active: {mac_address}")
return error_response(message="Device is not active")
user_device = UserDevice.objects.filter(device=device).first()
if not user_device:
logger.warning(f"Device not bound to any user: {mac_address}")
return error_response(message="Device is not bound to any user")
token = generate_token(user_device.user.id)
logger.info(f"Successfully logged in device with MAC: {mac_address}")
return success_response(
data={'token': token},
message="登录成功"
)
except Device.DoesNotExist:
logger.warning(f"Device not found: {mac_address}")
return error_response(message="Device not found")
except Exception as e:
logger.error(f"MAC address login failed: {str(e)}")
return error_response(message=f"Login failed: {str(e)}")
```
### 替换为
```python
def post(self, request):
mac_address = request.data.get('mac_address')
if not mac_address:
logger.warning("Attempt to login without MAC address")
return error_response(message="MAC address is required")
logger.info(f"Attempting MAC address login for device: {mac_address}")
try:
device = Device.objects.get(mac_address=mac_address)
# 检查设备是否已绑定给用户
user_device = UserDevice.objects.filter(device=device).first()
if not user_device:
logger.warning(f"Device not bound to any user: {mac_address}")
# 返回特定 code=4010让设备端可以识别"未绑定"状态
return error_response(
message="Device is not bound to any user",
code=4010,
data={
'device_code': device.device_code,
'mac_address': mac_address,
'bound': False
}
)
# 如果设备未激活,在登录时自动激活
if not device.is_active:
device.is_active = True
device.activated_at = timezone.now()
device.save()
logger.info(f"Device auto-activated on login: {mac_address}")
# 生成绑定用户的 token
token = generate_token(user_device.user.id)
logger.info(f"Successfully logged in device with MAC: {mac_address}, bound to user: {user_device.user.id}")
return success_response(
data={
'token': token,
'user_id': user_device.user.id,
'device_code': device.device_code,
'mac_address': mac_address,
'bound': True
},
message="登录成功"
)
except Device.DoesNotExist:
logger.warning(f"Device not found: {mac_address}")
return error_response(
message="Device not found",
code=4011,
data={
'mac_address': mac_address,
'registered': False
}
)
except Exception as e:
logger.error(f"MAC address login failed: {str(e)}")
return error_response(message=f"Login failed: {str(e)}")
```
### 需要新增的 import文件头部
`userapp/views.py` 顶部检查是否已有以下 import如果没有则添加
```python
from django.utils import timezone
```
### 验证方法
用 curl 或 Postman 测试:
```bash
# 测试1用已绑定设备的 MAC应返回 token + bound=true
curl -X POST https://qy-lty.airlabs.art/api/user/auth/mac/login/ \
-H "Content-Type: application/json" \
-d '{"mac_address": "00:55:11:44:f3:22"}'
# 期望响应:
# {"success": true, "code": 200, "data": {"token": "xxx", "user_id": 1, "device_code": "...", "bound": true}}
# 测试2用未绑定设备的 MAC应返回 code=4010 + bound=false
curl -X POST https://qy-lty.airlabs.art/api/user/auth/mac/login/ \
-H "Content-Type: application/json" \
-d '{"mac_address": "AA:BB:CC:DD:EE:FF"}'
# 期望响应:
# {"success": false, "code": 4010, "data": {"device_code": "...", "bound": false}}
# 测试3用不存在的 MAC应返回 code=4011 + registered=false
curl -X POST https://qy-lty.airlabs.art/api/user/auth/mac/login/ \
-H "Content-Type: application/json" \
-d '{"mac_address": "FF:FF:FF:FF:FF:FF"}'
# 期望响应:
# {"success": false, "code": 4011, "data": {"registered": false}}
```
### 与其他端的关联
- **设备端步骤 1** 依赖此步骤:设备端会根据 `code=4010` 判断自己未绑定,进入等待绑定状态
- **手机端步骤 0** 不依赖此步骤:手机端使用手机号登录(见手机端步骤 0登录 API 已存在无需改动
---
## 步骤 2新增设备绑定状态查询接口
### 目标
设备端需要轮询查询自己是否已被手机端绑定。新增一个不需要认证的接口,设备通过 MAC 地址查询绑定状态。
### 修改文件
`device_interaction/views.py` — 在 `DeviceViewSet` 类中新增 action
### 在 `DeviceViewSet` 类中添加方法
`update_status` 方法之后(约第 400 行后),添加:
```python
@swagger_auto_schema(
operation_summary="查询设备绑定状态",
operation_description="通过MAC地址查询设备是否已被用户绑定不需要认证",
manual_parameters=[mac_address_param],
responses={
200: openapi.Response('查询成功', get_standardized_response_schema()),
400: openapi.Response('参数错误', get_standardized_response_schema()),
404: openapi.Response('设备不存在', get_standardized_response_schema())
}
)
@action(detail=False, methods=['get'], permission_classes=[AllowAny], authentication_classes=[])
def bind_status(self, request):
"""
查询设备绑定状态
通过MAC地址查询设备是否已被用户绑定。
此接口不需要认证,供设备端轮询使用。
参数:
- mac_address: 设备MAC地址query参数
返回:
- bound: 是否已绑定
- user_id: 绑定用户ID仅已绑定时返回
- nickname: 设备昵称(仅已绑定时返回)
- device_code: 设备编码
"""
mac_address = request.query_params.get('mac_address')
if not mac_address:
return error_response(message='MAC地址不能为空', code=status.HTTP_400_BAD_REQUEST)
try:
device = Device.objects.get(mac_address=mac_address)
user_device = UserDevice.objects.filter(device=device).first()
if user_device:
return success_response(
data={
'bound': True,
'user_id': user_device.user.id,
'nickname': user_device.nickname or '',
'device_code': device.device_code,
'is_primary': user_device.is_primary
},
message='设备已绑定'
)
else:
return success_response(
data={
'bound': False,
'device_code': device.device_code,
'mac_address': mac_address
},
message='设备未绑定'
)
except Device.DoesNotExist:
return not_found_response(message='设备不存在')
```
### 验证方法
```bash
# 测试1查询已绑定设备
curl "https://qy-lty.airlabs.art/api/device/devices/bind_status/?mac_address=00:55:11:44:f3:22"
# 期望响应:
# {"success": true, "data": {"bound": true, "user_id": 1, "nickname": "...", "device_code": "..."}}
# 测试2查询未绑定设备
curl "https://qy-lty.airlabs.art/api/device/devices/bind_status/?mac_address=AA:BB:CC:DD:EE:FF"
# 期望响应:
# {"success": true, "data": {"bound": false, "device_code": "...", "mac_address": "AA:BB:CC:DD:EE:FF"}}
# 测试3查询不存在的设备
curl "https://qy-lty.airlabs.art/api/device/devices/bind_status/?mac_address=FF:FF:FF:FF:FF:FF"
# 期望响应:
# {"success": false, "message": "设备不存在"}
```
### 与其他端的关联
- **设备端步骤 1** 依赖此步骤:设备端轮询此接口等待被绑定
- **手机端步骤 2**DeviceBindManager不直接依赖但绑定后可以用此接口验证绑定结果
---
## 步骤 3新增设备自注册接口
### 目标
设备首次开机时,如果 MAC 地址在数据库中不存在,可以自动注册一个 Device 记录,无需管理员手动在后台录入。
### 修改文件
**1. `device_interaction/serializers.py`** — 新增 Serializer
在文件末尾添加:
```python
class DeviceRegisterSerializer(serializers.Serializer):
mac_address = serializers.CharField(max_length=17)
device_type_code = serializers.CharField(max_length=10, required=False, default='T01')
firmware_version = serializers.CharField(max_length=50, required=False, default='')
def validate_mac_address(self, value):
# 检查是否已存在
if Device.objects.filter(mac_address=value).exists():
raise serializers.ValidationError("设备已注册")
return value
def validate_device_type_code(self, value):
if not DeviceType.objects.filter(code=value).exists():
raise serializers.ValidationError(f"设备类型 {value} 不存在")
return value
```
**2. `device_interaction/views.py`** — 在 `DeviceViewSet` 中新增 action
在 import 区域补充(如果还没有):
```python
from .serializers import (
DeviceTypeSerializer, DeviceBatchSerializer, DeviceSerializer,
DeviceCreateSerializer, DeviceBatchCreateSerializer,
UserDeviceSerializer, DeviceBindSerializer, DeviceRegisterSerializer
)
```
`DeviceViewSet` 类中(`bind_status` 方法之后)添加:
```python
@swagger_auto_schema(
operation_summary="设备自注册",
operation_description="设备首次开机时自动注册(不需要认证)",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['mac_address'],
properties={
'mac_address': openapi.Schema(type=openapi.TYPE_STRING, description='设备MAC地址'),
'device_type_code': openapi.Schema(type=openapi.TYPE_STRING, description='设备类型代码默认T01'),
'firmware_version': openapi.Schema(type=openapi.TYPE_STRING, description='固件版本号'),
}
),
responses={
201: openapi.Response('注册成功', get_standardized_response_schema()),
400: openapi.Response('参数错误', get_standardized_response_schema()),
}
)
@action(detail=False, methods=['post'], permission_classes=[AllowAny], authentication_classes=[])
def register(self, request):
"""
设备自注册
设备首次开机时如果MAC地址不存在则自动注册设备。
如果MAC地址已存在返回已存在的设备信息。
此接口不需要认证。
"""
mac_address = request.data.get('mac_address')
if not mac_address:
return error_response(message='MAC地址不能为空', code=status.HTTP_400_BAD_REQUEST)
# 如果设备已存在,直接返回设备信息
existing_device = Device.objects.filter(mac_address=mac_address).first()
if existing_device:
return success_response(
data={
'device_code': existing_device.device_code,
'mac_address': existing_device.mac_address,
'is_new': False
},
message='设备已注册'
)
device_type_code = request.data.get('device_type_code', 'T01')
firmware_version = request.data.get('firmware_version', '')
try:
device_type = DeviceType.objects.get(code=device_type_code)
except DeviceType.DoesNotExist:
return error_response(message=f'设备类型 {device_type_code} 不存在', code=status.HTTP_400_BAD_REQUEST)
# 获取或创建一个默认批次
batch, _ = DeviceBatch.objects.get_or_create(
device_type=device_type,
batch_number=f'AUTO-{device_type_code}',
defaults={
'production_date': timezone.now().date(),
'quantity': 99999,
'description': '设备自动注册批次'
}
)
# 生成序列号
existing_count = Device.objects.filter(batch=batch).count()
serial_number = f"{existing_count + 1:05d}"
try:
device = Device(
device_type=device_type,
batch=batch,
serial_number=serial_number,
mac_address=mac_address,
firmware_version=firmware_version
)
device.save()
return created_response(
data={
'device_code': device.device_code,
'mac_address': device.mac_address,
'is_new': True
},
message='设备注册成功'
)
except Exception as e:
logger.error(f"Device registration failed: {str(e)}")
return error_response(message=f'注册失败: {str(e)}', code=status.HTTP_500_INTERNAL_SERVER_ERROR)
```
### 验证方法
```bash
# 测试1注册新设备
curl -X POST https://qy-lty.airlabs.art/api/device/devices/register/ \
-H "Content-Type: application/json" \
-d '{"mac_address": "AA:BB:CC:DD:EE:01", "device_type_code": "T01"}'
# 期望响应:
# {"success": true, "code": 201, "data": {"device_code": "T01-AUTO-T01-00001", "is_new": true}}
# 测试2重复注册应返回已存在
curl -X POST https://qy-lty.airlabs.art/api/device/devices/register/ \
-H "Content-Type: application/json" \
-d '{"mac_address": "AA:BB:CC:DD:EE:01"}'
# 期望响应:
# {"success": true, "data": {"device_code": "...", "is_new": false}}
```
### 与其他端的关联
- **设备端步骤 1** 依赖此步骤:设备端首次开机时如果 MAC 登录返回 `code=4011`(设备不存在),会调用此接口自动注册
- **手机端**不依赖此步骤
---
## 步骤 4为绑定接口增加 mac_address 返回字段
### 目标
当前 `UserDeviceSerializer` 没有返回设备的 `mac_address`,手机端需要这个字段来管理设备。
### 修改文件
`device_interaction/serializers.py``UserDeviceSerializer`
### 当前代码(第 65-78 行)
```python
class UserDeviceSerializer(serializers.ModelSerializer):
device_code = serializers.ReadOnlyField(source='device.device_code')
device_type = serializers.ReadOnlyField(source='device.device_type.name')
device_status = serializers.ReadOnlyField(source='device.status')
battery_level = serializers.ReadOnlyField(source='device.battery_level')
firmware_version = serializers.ReadOnlyField(source='device.firmware_version')
wifi_name = serializers.ReadOnlyField(source='device.wifi_name')
wifi_password = serializers.ReadOnlyField(source='device.wifi_password')
brightness = serializers.ReadOnlyField(source='device.brightness')
class Meta:
model = UserDevice
fields = '__all__'
read_only_fields = ('user', 'bound_at')
```
### 替换为
```python
class UserDeviceSerializer(serializers.ModelSerializer):
device_code = serializers.ReadOnlyField(source='device.device_code')
mac_address = serializers.ReadOnlyField(source='device.mac_address')
device_type = serializers.ReadOnlyField(source='device.device_type.name')
device_status = serializers.ReadOnlyField(source='device.status')
battery_level = serializers.ReadOnlyField(source='device.battery_level')
firmware_version = serializers.ReadOnlyField(source='device.firmware_version')
wifi_name = serializers.ReadOnlyField(source='device.wifi_name')
wifi_password = serializers.ReadOnlyField(source='device.wifi_password')
brightness = serializers.ReadOnlyField(source='device.brightness')
class Meta:
model = UserDevice
fields = '__all__'
read_only_fields = ('user', 'bound_at')
```
(仅增加了一行 `mac_address`
### 验证方法
```bash
# 用已登录用户的 token 查询绑定设备列表
curl -H "Authorization: Bearer <user_token>" \
https://qy-lty.airlabs.art/api/device/user-devices/
# 期望响应中每个设备对象现在包含 mac_address 字段
```
### 与其他端的关联
- **手机端步骤 2**DeviceBindManager依赖此步骤手机端解析设备列表时需要 mac_address
---
## 步骤 5新增 device_info WebSocket 消息类型
### 目标
设备端连上 WiFi 并建立 WebSocket 后,需要把自己的 MAC 地址、电量、固件版本等信息上报给服务器和手机端。新增 `device_info` 消息类型实现这个功能。
### 背景:手机端不需要 MAC 地址做通信
手机端通过 `user_id` 标识(来自登录 token不是 MAC 地址。手机切换 WiFi/4G/5G 不影响 WebSocket 通信。但手机端需要知道设备的状态信息(电量、在线状态等),所以设备端通过 WebSocket 上报 `device_info` 消息。
### 修改文件
`device_interaction/consumers.py`
### 在 receive 方法中添加 device_info 处理
找到 `receive` 方法中 `elif message_type == 'factory_reset':` 分支(约第 257 行),在它**之前**添加:
```python
elif message_type == 'device_info':
# 处理设备信息上报设备连WiFi后发送MAC、电量等
try:
device_data = json.loads(message) if isinstance(message, str) else message
# 更新数据库中的设备状态
mac_address = device_data.get('mac_address')
if mac_address:
await self.update_device_status(mac_address, device_data)
# 广播到 group让手机端也能收到
await self.channel_layer.group_send(
self.group_name,
{
'type': 'device_info',
'message': device_data,
'user_id': self.user_id
}
)
await self.send(text_data=json.dumps({
'status': 'success',
'message': '设备信息已更新'
}))
except Exception as e:
logger.error(f"Error processing device_info: {str(e)}")
await self.send(text_data=json.dumps({
'status': 'error',
'message': f'设备信息处理失败: {str(e)}'
}))
```
### 在 Consumer 类中添加 device_info 事件处理方法
`conversation_subtitle` 方法之后(约第 476 行后),添加:
```python
async def device_info(self, event):
"""
处理设备信息上报消息
将设备信息转发给 WebSocket手机端会收到
"""
try:
message = event['message']
user_id = event.get('user_id', '')
await self.send(text_data=json.dumps({
'type': 'device_info',
'message': message,
'user_id': user_id
}))
logger.info(f"Sent device_info to WebSocket: mac={message.get('mac_address', 'unknown')}")
except Exception as e:
logger.error(f"Error in device_info: {str(e)}")
```
### 在 Consumer 类中添加数据库更新辅助方法
`authenticate_with_token` 方法之后添加:
```python
@database_sync_to_async
def update_device_status(self, mac_address, device_data):
"""
根据设备上报信息更新数据库中的设备状态
"""
try:
from .models import Device
device = Device.objects.filter(mac_address=mac_address).first()
if device:
if 'battery_level' in device_data:
device.battery_level = device_data['battery_level']
if 'firmware_version' in device_data:
device.firmware_version = device_data['firmware_version']
if 'wifi_name' in device_data:
device.wifi_name = device_data['wifi_name']
if 'brightness' in device_data:
device.brightness = device_data['brightness']
device.status = 'connected'
device.save()
logger.info(f"Updated device status for MAC: {mac_address}")
except Exception as e:
logger.error(f"Failed to update device status: {str(e)}")
```
### 验证方法
使用 WebSocket 客户端工具(如 wscat 或浏览器控制台)连接 WebSocket 后发送:
```json
{
"type": "device_info",
"message": "{\"mac_address\":\"00:55:11:44:f3:22\",\"battery_level\":85,\"firmware_version\":\"1.0.0\",\"wifi_name\":\"TestWiFi\"}"
}
```
预期:
1. 服务器返回 `{"status": "success", "message": "设备信息已更新"}`
2. 同 group 的其他连接(手机端)收到 `{"type": "device_info", "message": {...}}`
3. 数据库中 Device 记录的 battery_level/firmware_version 等字段已更新
### 与其他端的关联
- **设备端步骤 3** 依赖此步骤:设备端 WebSocket 连接成功后会发送 `device_info` 消息
- **手机端步骤 3B** 依赖此步骤:手机端需要处理收到的 `device_info` 消息
---
## 步骤 6部署验证 & 三端联调检查清单
### 部署步骤
```bash
# 1. 拉取代码
cd /path/to/qy_lty-main
git pull
# 2. 检查是否需要 migrate本次无模型变更不需要
# 3. 重启服务
# Docker 环境:
docker-compose restart
# 或 直接 daphne
daphne -b 0.0.0.0 -p 8000 qy_lty.asgi:application
```
### API 验证清单
| # | 接口 | 方法 | 预期 |
|---|------|------|------|
| 1 | `/api/user/auth/mac/login/` | POST | 已绑定:返回 token+bound=true未绑定code=4010不存在code=4011 |
| 2 | `/api/device/devices/bind_status/` | GET | 返回 bound=true/false |
| 3 | `/api/device/devices/register/` | POST | 新设备返回 is_new=true已存在返回 is_new=false |
| 4 | `/api/device/user-devices/bind/` | POST | 传 mac_address 绑定成功 |
| 5 | `/api/device/user-devices/` | GET | 返回设备列表,含 mac_address 字段 |
| 6 | `/api/device/user-devices/{id}/` | DELETE | 解绑成功 |
| 7 | `/api/user/auth/phone/login/` | POST | 手机号登录返回 token已有无需改动 |
### 与设备端联调
> 需要**设备端步骤 1-3** 全部完成
1. 设备端开机 → 调用 MAC 登录 → 收到 `code=4010`(未绑定)→ 正确进入等待绑定状态 ✓(设备端步骤 1
2. 设备端调用 `bind_status` 轮询 → 返回 `bound=false` ✓(设备端步骤 1
3. 手机端绑定后 → 设备端轮询 `bind_status` → 返回 `bound=true` ✓(设备端步骤 1
4. 设备端重新 MAC 登录 → 返回 token + `bound=true` → 连接 WebSocket ✓(设备端步骤 1
5. 设备端连 WebSocket 后上报 `device_info` → 服务器更新数据库 → 手机端收到 ✓(设备端步骤 3
### 与手机端联调
> 需要**手机端步骤 0-4** 全部完成(步骤 0 为手机号登录功能)
1. 手机端手机号+验证码登录 → 获得 user_token ✓(手机端步骤 0
2. 手机端调用 `bind` 接口 → 绑定成功 ✓(手机端步骤 2 DeviceBindManager
3. 手机端查询设备列表 → 含 mac_address 字段 ✓(手机端步骤 2
4. 手机端连 WebSocket → 进入 `device_{user_id}` group ✓(手机端步骤 1
5. 手机端发 touch 消息 → 设备端收到 ✓
6. 手机端收到设备端 `device_info` 上报 ✓(手机端步骤 3B

View File

@ -0,0 +1,32 @@
# 服务器端代码修改记录
本文档记录每次对服务器端代码的修改,方便追踪变更历史。
---
## 修改格式说明
每次修改按以下格式记录:
```
### [日期] 修改简述
- **文件路径**: 相对于项目根目录的文件路径
- **修改类型**: 新增 / 修改 / 删除 / 重构 / 修复Bug
- **修改内容**: 具体修改了什么
- **修改原因**: 为什么要做这个修改
```
---
## 修改历史
<!-- 新的修改记录添加在此处下方,最新的在最前面 -->
### [2026-03-17] 修复手机号登录时 IntegrityError
- **文件路径**: `userapp/views.py`
- **修改类型**: 修复Bug
- **修改内容**: `PhoneLoginView.post()``get_or_create` 新增 `defaults={'username': phone_number}`
- **修改原因**: 新用户首次通过手机号登录时,`get_or_create` 未设置 `username` 字段,导致 `username=""` 与数据库中已有空 username 记录冲突,触发 `IntegrityError: duplicate key value violates unique constraint "userapp_paradiseuser_username_key"`。改为用手机号作为默认 username保证唯一性。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,238 @@
# QY LTY 后端服务 — 项目功能介绍
## 项目定位
QY LTY 是一个 AI 智能硬件(如陪伴机器人/智能玩具)的后端平台,基于 Django 构建,为设备提供 AI 对话、语音交互、实时通信能力,同时管理用户账号、卡密激活、订阅付费和成就激励体系。
---
## 代码目录结构
| 目录 | 说明 |
|------|------|
| `qy_lty/` | Django 主配置settings、urls、asgi |
| `userapp/` | 用户管理与认证 |
| `aiapp/` | AI 对话系统 |
| `card/` | 卡片/卡密系统 |
| `device_interaction/` | 设备 WebSocket 实时通信 |
| `achievement_app/` | 成就系统 |
| `subscription_app/` | 订阅管理 |
| `ali_vi_app/` | 阿里云视觉智能(换脸/美颜等) |
| `food_app/` | 食物系统(游戏道具) |
| `workflow_app/` | 工作流管理(开发中) |
| `common/` | 公共工具响应格式、OSS、中间件 |
| `docs/` | 项目文档 |
---
## 功能模块详解
### 1. 用户管理userapp/
- 自定义用户模型 `ParadiseUser`扩展字段手机号、性别、生日、星座、MBTI、兴趣、社交身份
- 多种登录方式手机号、邮箱、用户名、MAC 地址
- 阿里云短信验证码
- 微信社交登录django-allauth
- Redis Token 认证
**核心文件:**
- `models.py` — ParadiseUser 用户模型
- `views.py` — 注册、登录、资料更新接口
- `authentication.py` — RedisTokenAuthentication 自定义认证
- `utils.py` — 短信发送、Token 生成与管理
---
### 2. AI 对话系统aiapp/
- **单轮对话:** `/api/ai/chat/<bot_id>/`
- **多轮对话:** `/api/ai/multichat/`(保持上下文)
- 接入 Kimi月之暗面大模型
- **语音服务:** 多供应商抽象架构
- `audio/AliyunAudioService.py` — 阿里云语音
- `audio/TencentAudioService.py` — 腾讯云语音
- `audio/HuoshanAudioService.py` — 火山引擎语音
- 支持语音合成TTS和语音识别ASR
**核心文件:**
- `models.py` — Bot 机器人模型、ChatMessage 对话记录模型
- `views.py` — ChatBotAPIView单轮、MultiChatAPIView多轮
- `kimi.py` — Kimi API 客户端OpenAI 兼容接口)
- `audio/AudioService.py` — 音频服务工厂函数
- `audio/BaseAudioService.py` — 音频服务抽象基类
---
### 3. 设备实时通信device_interaction/
- WebSocket 连接:`ws://domain/ws/device/token/{token}/`
- 设备模型设备类型、批次、序列号、电量、固件版本、WiFi、亮度
- 用户-设备绑定关系
- 消息类型:`chat_message`(聊天)、`weather`(天气)、`sing`(唱歌)、`dance`(跳舞)
- 集成和风天气 API、高德地图 API
- 火山引擎 RTC 实时音视频
**核心文件:**
- `models.py` — DeviceType、DeviceBatch、Device、UserDevice 模型
- `consumers.py` — DeviceConsumer WebSocket 消费者
- `views.py` — 设备管理 API
- `services.py` — Redis 设备连接状态追踪
- `auth.py` — TokenAuthMiddleware WebSocket 认证中间件
- `weather.py` — 和风天气 API 集成
- `amap_api.py` — 高德地图定位服务
- `volcengine_api.py` — 火山引擎 RTC 集成
---
### 4. 卡片/卡密系统card/
- 卡片模板:分类(服装、道具、歌曲、舞蹈、家具、装饰)、稀有度
- 批量生成卡密,支持 Excel 导出
- 二维码生成和扫码核销
- 使用日志追踪(扫描、使用、作废、发布、生产)
- 每个分类有独立属性模型ClothingAttributes、PropAttributes 等)
**核心文件:**
- `models.py` — CardTemplate、Card、CardBatch、CardUsageLog 及各分类属性模型
- `views.py` — 模板 CRUD、批量生成、扫码核销、Excel 导出
- `storage.py` — OSSStorage 阿里云对象存储集成
---
### 5. 成就系统achievement_app/
- 成就定义名称、描述、图标、达成条件JSON
- 稀有度:普通 / 珍贵 / 稀有 / 史诗 / 传说
- 类型:登录 / 活动 / 社交 / 使用 / 特殊 / 隐藏
- 用户成就进度追踪与通知
**核心文件:**
- `models.py` — Achievement 成就定义、UserAchievement 用户成就记录
---
### 6. 订阅管理subscription_app/
- 订阅计划:月付 / 季付 / 年付
- 增值包购买AddOnPackage
- 计费记录TenantBilling
- 定时任务处理订阅续期
**核心文件:**
- `models.py` — SubscriptionPlan、Subscription、AddOnPackage、SubscriptionAddOn、TenantBilling
- `scheduler.py` — 定时任务
- `tasks.py` — 异步任务
---
### 7. 阿里云视觉智能ali_vi_app/
- 人脸融合/换脸
- 人脸美颜
- 面部液化、妆容、肤质优化
**核心文件:**
- `vi.py` — VI 类,封装阿里云 FaceBody APIadd_template、merge_image_face、face_beauty 等)
---
### 8. 食物系统food_app/
- 食物道具:水果、蔬菜、肉类、海鲜等 11 种分类
- 稀有度等级、卡路里、口味标签、营养值
- 游戏效果、使用次数限制、上架时间窗口
- 用户食物背包、使用日志
**核心文件:**
- `models.py` — Food 食物定义、UserFood 用户背包、FoodUsageLog 使用日志
---
### 9. 公共基础设施common/
- 标准化 API 响应格式:`{success, code, message, data}`
- 阿里云 OSS 文件上传
- 阿里云日志服务
- 自定义分页(默认 10 条,最大 100 条)
- 火山引擎 Token 管理
**核心文件:**
- `responses.py` — api_response、success_response、error_response 等标准响应函数
- `middleware.py` — StandardResponseMiddleware 统一响应中间件
- `pagination.py` — CustomPageNumberPagination 分页器
- `oss.py` — OSSUploader 文件上传工具
- `aliyun_logging.py` — 阿里云日志配置
---
## API 接口一览
| 路径 | 说明 |
|------|------|
| `/api/user/` | 用户认证与管理 |
| `/api/ai/` | AI 对话接口 |
| `/api/device/` | 设备管理与消息推送 |
| `/api/card/` | 卡片系统管理 |
| `/api/achievement/` | 成就系统 |
| `/api/v1/admin/` | 管理后台接口 |
| `/swagger/` | Swagger API 文档 |
| `/redoc/` | ReDoc API 文档 |
**WebSocket 端点:**
- `ws://domain/ws/device/` — 设备连接
- `ws://domain/ws/device/token/{token}/` — 带 Token 认证的设备连接
---
## 技术栈
| 层面 | 技术 |
|------|------|
| 框架 | Django 4.2.13 + Django REST Framework |
| 实时通信 | Django Channels + Daphne ASGI |
| 数据库 | PostgreSQL |
| 缓存/消息 | Redis |
| AI 模型 | KimiOpenAI 兼容接口) |
| 语音服务 | 阿里云 / 腾讯 / 火山引擎(可配置切换) |
| 文件存储 | 阿里云 OSS |
| 短信服务 | 阿里云 SMS |
| 视觉智能 | 阿里云 FaceBody |
| 地图/天气 | 高德地图 / 和风天气 |
| 音视频 | 火山引擎 RTC |
| 部署 | Docker + docker-compose |
| API 文档 | drf-yasgSwagger / ReDoc |
| 后台管理 | django-simpleui |
---
## 环境配置
### 必需环境变量(.env
- `SECRET_KEY` — Django 密钥
- `DEBUG` — 调试模式
- `POSTGRESQL_DATABASE_*` — 数据库连接
- `REDIS_LOCATION``REDIS_PASSWORD` — Redis 配置
- `KIMI_API_KEY``KIMI_BASE_URL` — Kimi AI 配置
- `ALIYUN_*` — 阿里云各服务凭证
- `VOLCENGINE_ACCESS_KEY``VOLCENGINE_SECRET_KEY` — 火山引擎配置
- `AUDIO_SERVICE_PROVIDER` — 语音服务商选择
### 快速启动
```bash
# 安装依赖
pip install -r requirements.txt
# 配置环境变量
cp .env.bak .env
# 数据库迁移
python manage.py migrate
# 启动服务(支持 WebSocket
daphne -b 0.0.0.0 -p 8000 qy_lty.asgi:application
# Docker 部署
docker-compose up -d --build
```

View File

@ -32,12 +32,15 @@ class FoodListSerializer(serializers.ModelSerializer):
food_type_display = serializers.ReadOnlyField(source='get_food_type_display')
is_available = serializers.ReadOnlyField()
status_display = serializers.ReadOnlyField(source='get_status_display')
class Meta:
model = Food
fields = [
'id', 'name', 'food_type', 'food_type_display', 'rarity',
'rarity_display', 'image', 'calories', 'is_available',
'is_limited', 'created_at'
'is_limited', 'status', 'status_display', 'published_at',
'created_at'
]

View File

@ -39,11 +39,11 @@ class FoodViewSet(viewsets.ModelViewSet):
def get_queryset(self):
"""获取食物列表"""
# 管理员可以看到所有食物,普通用户只能看到已发布的食物
if self.action in ['list', 'retrieve']:
# 对于列表和详情查看,只显示已发布的食物
if self.request.user.is_staff or self.request.user.is_superuser:
queryset = Food.objects.all()
elif self.action in ['list', 'retrieve']:
queryset = Food.objects.filter(status='published')
else:
# 对于创建、更新、删除操作,显示所有食物
queryset = Food.objects.all()
# 筛选参数
@ -86,6 +86,57 @@ class FoodViewSet(viewsets.ModelViewSet):
'statuses': Food.STATUS_CHOICES
})
@swagger_auto_schema(
operation_description="发布食物",
responses={
200: openapi.Response(description="发布成功"),
400: "食物状态不允许发布"
}
)
@action(detail=True, methods=['post'])
def publish(self, request, pk=None):
"""发布食物"""
food = self.get_object()
if food.status == 'published':
return Response(
{'error': '该食物已经是发布状态'},
status=status.HTTP_400_BAD_REQUEST
)
if food.status == 'archived':
return Response(
{'error': '已归档的食物不能发布'},
status=status.HTTP_400_BAD_REQUEST
)
food.publish()
serializer = self.get_serializer(food)
return Response({
'message': f'食物 "{food.name}" 发布成功',
'food': serializer.data
})
@swagger_auto_schema(
operation_description="归档食物",
responses={
200: openapi.Response(description="归档成功"),
400: "食物状态不允许归档"
}
)
@action(detail=True, methods=['post'])
def archive(self, request, pk=None):
"""归档食物"""
food = self.get_object()
if food.status == 'archived':
return Response(
{'error': '该食物已经是归档状态'},
status=status.HTTP_400_BAD_REQUEST
)
food.archive()
serializer = self.get_serializer(food)
return Response({
'message': f'食物 "{food.name}" 已归档',
'food': serializer.data
})
class UserFoodViewSet(viewsets.ReadOnlyModelViewSet):
"""

View File

@ -376,25 +376,34 @@ LOGGING = {
'level': 'INFO',
'class': 'common.aliyun_logging.AliyunLogHandler',
},
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django': {
'handlers': ['aliyun'],
'handlers': ['aliyun', 'console'],
'level': 'INFO',
'propagate': True,
},
'django.request': {
'handlers': ['console'],
'level': 'ERROR',
'propagate': False,
},
'aiapp': {
'handlers': ['aliyun'],
'level': 'INFO', # 例如为myapp应用设置DEBUG级别的日志记录
'handlers': ['aliyun', 'console'],
'level': 'INFO',
'propagate': True,
},
'common': {
'handlers': ['aliyun'],
'handlers': ['aliyun', 'console'],
'level': 'INFO',
'propagate': True,
},
'userapp': {
'handlers': ['aliyun'],
'handlers': ['aliyun', 'console'],
'level': 'INFO',
'propagate': True,
},
@ -638,10 +647,10 @@ Cache Configuration:
logger.info("="*50)
logger.info(db_info)
logger.info(f"Database Status: {db_status}")
logger.info(f"Database Latency: {db_latency:.2f}ms")
logger.info(f"Database Latency: {db_latency if isinstance(db_latency, str) else f'{db_latency:.2f}'}ms")
logger.info(cache_info)
logger.info(f"Cache Status: {cache_status}")
logger.info(f"Cache Latency: {cache_latency:.2f}ms")
logger.info(f"Cache Latency: {cache_latency if isinstance(cache_latency, str) else f'{cache_latency:.2f}'}ms")
logger.info("="*50)
# Call on application startup

View File

@ -8,6 +8,9 @@ import logging
logger = logging.getLogger(__name__)
class RedisTokenAuthentication(BaseAuthentication):
def authenticate_header(self, request):
return 'Bearer realm="api"'
def authenticate(self, request):
authorization = request.headers.get('Authorization')
if not authorization:

View File

@ -0,0 +1,49 @@
# Generated by Django 5.2.12 on 2026-03-18 06:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('userapp', '0003_paradiseuser_birthday_paradiseuser_favorability_and_more'),
]
operations = [
migrations.CreateModel(
name='AffinityLevel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('level', models.IntegerField(unique=True, verbose_name='等级')),
('name', models.CharField(max_length=50, verbose_name='等级名称')),
('description', models.TextField(blank=True, verbose_name='等级描述')),
('required_points', models.IntegerField(verbose_name='所需积分')),
('rewards', models.JSONField(default=list, verbose_name='奖励')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '好感度等级',
'verbose_name_plural': '好感度等级',
'ordering': ['level'],
},
),
migrations.CreateModel(
name='AffinityRule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='规则名称')),
('description', models.TextField(blank=True, verbose_name='规则描述')),
('points', models.IntegerField(default=0, verbose_name='积分')),
('daily_limit', models.IntegerField(blank=True, null=True, verbose_name='每日上限')),
('is_active', models.BooleanField(default=True, verbose_name='已启用')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '好感度规则',
'verbose_name_plural': '好感度规则',
'ordering': ['-created_at'],
},
),
]

View File

@ -74,3 +74,41 @@ class ParadiseUser(AbstractUser):
# related_name='auth_token',
# on_delete=models.CASCADE
# )
class AffinityRule(models.Model):
"""好感度规则:定义哪些行为可以获得好感度积分"""
name = models.CharField('规则名称', max_length=100)
description = models.TextField('规则描述', blank=True)
points = models.IntegerField('积分', default=0)
daily_limit = models.IntegerField('每日上限', null=True, blank=True)
is_active = models.BooleanField('已启用', default=True)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True)
class Meta:
verbose_name = '好感度规则'
verbose_name_plural = '好感度规则'
ordering = ['-created_at']
def __str__(self):
return self.name
class AffinityLevel(models.Model):
"""好感度等级:定义不同好感度区间对应的等级和奖励"""
level = models.IntegerField('等级', unique=True)
name = models.CharField('等级名称', max_length=50)
description = models.TextField('等级描述', blank=True)
required_points = models.IntegerField('所需积分')
rewards = models.JSONField('奖励', default=list)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True)
class Meta:
verbose_name = '好感度等级'
verbose_name_plural = '好感度等级'
ordering = ['level']
def __str__(self):
return f"Lv{self.level} {self.name}"

View File

@ -2,10 +2,14 @@ from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
ParadiseUserViewSet, CustomRegisterView, PhoneLoginView,
SendVerifyCodeView, EmailLoginView, UsernameLoginView, MacAddressLoginView
SendVerifyCodeView, EmailLoginView, UsernameLoginView, MacAddressLoginView,
GroupViewSet, AffinityRuleViewSet, AffinityLevelViewSet
)
router = DefaultRouter()
router.register('groups', GroupViewSet, basename='group')
router.register('affinity-rules', AffinityRuleViewSet, basename='affinity-rule')
router.register('affinity-levels', AffinityLevelViewSet, basename='affinity-level')
router.register('', ParadiseUserViewSet)
# 用户视图集的登录相关路径

View File

@ -1,9 +1,10 @@
from dj_rest_auth.registration.views import RegisterView
from rest_framework import viewsets
from .models import ParadiseUser
from .models import ParadiseUser, AffinityRule, AffinityLevel
from device_interaction.models import Device, UserDevice
from .serializers import ParadiseUserSerializer, CustomRegisterSerializer, UserInfoSerializer, ProfileUpdateSerializer
from rest_framework import viewsets, status
from django.contrib.auth.models import Group, Permission
from rest_framework.response import Response
from rest_framework.decorators import action, permission_classes, authentication_classes
from rest_framework.permissions import AllowAny, IsAuthenticated
@ -440,6 +441,93 @@ class BindPhoneRequestSchema(serializers.Serializer):
phone_number = serializers.CharField(required=True, help_text="手机号码")
code = serializers.CharField(required=True, help_text="验证码")
class GroupSerializer(serializers.ModelSerializer):
user_count = serializers.SerializerMethodField()
permissions = serializers.SerializerMethodField()
class Meta:
model = Group
fields = ['id', 'name', 'user_count', 'permissions']
def get_user_count(self, obj):
return obj.paradiseuser_set.count()
def get_permissions(self, obj):
return list(obj.permissions.values_list('codename', flat=True))
class GroupViewSet(viewsets.ModelViewSet):
"""
角色(用户组)管理接口
仅管理员可访问
"""
queryset = Group.objects.all()
serializer_class = GroupSerializer
permission_classes = [IsAuthenticated]
authentication_classes = [RedisTokenAuthentication]
def get_permissions(self):
if self.action in ['list', 'retrieve']:
return [IsAuthenticated()]
return [IsAuthenticated()]
def get_queryset(self):
if not self.request.user.is_staff:
return Group.objects.none()
return Group.objects.all()
class AffinityRuleSerializer(serializers.ModelSerializer):
class Meta:
model = AffinityRule
fields = ['id', 'name', 'description', 'points', 'daily_limit', 'is_active', 'created_at', 'updated_at']
class AffinityLevelSerializer(serializers.ModelSerializer):
class Meta:
model = AffinityLevel
fields = ['id', 'level', 'name', 'description', 'required_points', 'rewards', 'created_at', 'updated_at']
class AffinityRuleViewSet(viewsets.ModelViewSet):
"""
好感度规则管理接口
仅管理员可写认证用户可读
"""
queryset = AffinityRule.objects.all()
serializer_class = AffinityRuleSerializer
permission_classes = [IsAuthenticated]
authentication_classes = [RedisTokenAuthentication]
def get_permissions(self):
if self.action in ['list', 'retrieve']:
return [IsAuthenticated()]
if not self.request.user.is_staff:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied()
return [IsAuthenticated()]
class AffinityLevelViewSet(viewsets.ModelViewSet):
"""
好感度等级管理接口
仅管理员可写认证用户可读
"""
queryset = AffinityLevel.objects.all()
serializer_class = AffinityLevelSerializer
permission_classes = [IsAuthenticated]
authentication_classes = [RedisTokenAuthentication]
def get_permissions(self):
if self.action in ['list', 'retrieve']:
return [IsAuthenticated()]
if not self.request.user.is_staff:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied()
return [IsAuthenticated()]
class ParadiseUserViewSet(viewsets.ModelViewSet):
"""
用户管理接口
@ -642,7 +730,18 @@ class AdminEmailLoginView(APIView):
# 使用is_admin=True生成管理员专用token
token = generate_token(user.id, is_admin=True)
logger.info(f"Admin logged in with email: {email}")
return success_response(data={'token': token}, message="管理员登录成功")
# 获取用户角色名称(取第一个分组名称)
role_name = "超级管理员" if user.is_superuser else None
if not role_name:
group = user.groups.first()
role_name = group.name if group else "管理员"
return success_response(data={
'token': token,
'is_superuser': user.is_superuser,
'role': role_name,
}, message="管理员登录成功")
except ParadiseUser.DoesNotExist:
pass