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" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, use } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { DashboardShell } from "@/components/dashboard-shell" import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header" 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 { AddPrintBatchDialog } from "@/components/food/add-print-batch-dialog"
import { ExportCardsDialog } from "@/components/food/export-cards-dialog" import { ExportCardsDialog } from "@/components/food/export-cards-dialog"
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
import { isSuperUser } from "@/lib/api/auth"
import { getFood } from "@/lib/api/food" import { getFood } from "@/lib/api/food"
import type { Food } from "@/components/food/food-detail-dialog" 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 { toast } = useToast()
const [food, setFood] = useState<FoodWithBatches | null>(null) const [food, setFood] = useState<FoodWithBatches | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -39,7 +41,7 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
try { try {
setLoading(true) setLoading(true)
setError(null) setError(null)
const response = await getFood(params.id) const response = await getFood(id)
if (response.success && response.data) { if (response.success && response.data) {
// 为演示目的,添加一些模拟的批次数据 // 为演示目的,添加一些模拟的批次数据
@ -84,7 +86,7 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
useEffect(() => { useEffect(() => {
fetchFoodDetail() fetchFoodDetail()
}, [params.id]) }, [id])
if (loading) { if (loading) {
return ( return (
@ -104,7 +106,7 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" /> <AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-2"></h1> <h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-gray-500 mb-6"> <p className="text-gray-500 mb-6">
{error || `找不到ID为 ${params.id} 的食物`} {error || `找不到ID为 ${id} 的食物`}
</p> </p>
<Button asChild> <Button asChild>
<Link href="/food"> <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 ( return (
<DashboardShell> <DashboardShell>
@ -132,12 +148,12 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
</Button> </Button>
<DashboardHeader heading={food.name} text={`食物ID: ${food.id}`}> <DashboardHeader heading={food.name} text={`食物ID: ${food.id}`}>
<div className="flex space-x-2 ml-auto"> <div className="flex space-x-2 ml-auto">
{!isPublished && ( {(!isPublished || isSuperUser()) && (
<Button <Button
asChild 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" 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" /> <Edit className="mr-2 h-4 w-4" />
</Link> </Link>
@ -163,9 +179,18 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
</CardHeader> </CardHeader>
<CardContent className="flex justify-center"> <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"> <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"> <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>
</div> </div>
</CardContent> </CardContent>
@ -179,11 +204,11 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <p className="text-sm font-medium text-gray-500"></p>
@ -191,11 +216,11 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <p className="text-sm font-medium text-gray-500"></p>
@ -203,14 +228,18 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
</div> </div>
<div className="col-span-2 space-y-1"> <div className="col-span-2 space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
</div> </div>
{isPublished && ( {isPublished && (
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start"> <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" /> <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> </div>
)} )}
</CardContent> </CardContent>

View File

@ -8,13 +8,15 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge" 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 { AddFoodDialog } from "@/components/food/add-food-dialog"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog" import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
import { isSuperUser } from "@/lib/api/auth"
import Link from "next/link" import Link from "next/link"
import type { Food } from "@/components/food/food-detail-dialog" 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() { export default function FoodPage() {
const { toast } = useToast() const { toast } = useToast()
@ -35,6 +37,7 @@ export default function FoodPage() {
'published': '已发布', 'published': '已发布',
'draft': '草稿', 'draft': '草稿',
'pending': '待审核', 'pending': '待审核',
'archived': '已归档',
} }
return statusMap[status] || status 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) setSelectedFood(food)
}
} catch (error) {
console.error("获取食物详情失败:", error)
setSelectedFood(food)
}
setIsEditDialogOpen(true) setIsEditDialogOpen(true)
} }
@ -177,14 +228,16 @@ export default function FoodPage() {
<TableRow key={food.id} className="hover:bg-gray-50 transition-colors"> <TableRow key={food.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">{food.id}</TableCell> <TableCell className="font-medium">{food.id}</TableCell>
<TableCell className="font-medium text-pink-600">{food.name}</TableCell> <TableCell className="font-medium text-pink-600">{food.name}</TableCell>
<TableCell>{food.food_type}</TableCell> <TableCell>{food.food_type_display || food.food_type}</TableCell>
<TableCell>{food.rarity}</TableCell> <TableCell>{food.rarity_display || food.rarity}</TableCell>
<TableCell>{food.releaseDate || "-"}</TableCell> <TableCell>{food.releaseDate || "-"}</TableCell>
<TableCell> <TableCell>
<Badge <Badge
className={ className={
(food.status === "published" || food.status === "已发布") (food.status === "published" || food.status === "已发布")
? "bg-green-500 hover:bg-green-600" ? "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" : "bg-gray-500 hover:bg-gray-600"
} }
> >
@ -200,7 +253,30 @@ export default function FoodPage() {
</Link> </Link>
</Button> </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 <Button
variant="ghost" 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 { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header" import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" 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 Link from "next/link"
import { AddPrintBatchDialog } from "@/components/home-decor/add-print-batch-dialog" import { AddPrintBatchDialog } from "@/components/home-decor/add-print-batch-dialog"
import { ExportCardsDialog } from "@/components/home-decor/export-cards-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 export default function HomeDecorDetailPage({ params }: { params: Promise<{ id: string }> }) {
const decorData = { const { id } = use(params)
DEC001: { const [decor, setDecor] = useState<HomeDecor | null>(null)
id: "DEC001", const [loading, setLoading] = useState(true)
name: "星空投影灯", const [error, setError] = useState<string | null>(null)
type: "灯饰",
rarity: "稀有", useEffect(() => {
description: "可以在房间内投影出美丽的星空,营造浪漫氛围。", const fetchDecor = async () => {
releaseDate: "2023-10-20", try {
status: "已发布", setLoading(true)
activatedCount: 1342, setError(null)
printedCount: 2500, const data = await getHomeDecor(id)
image: "/placeholder.svg?height=300&width=300", setDecor(data)
batches: [ } catch (err) {
{ console.error("获取家居装饰详情失败:", err)
id: "B001", setError(`找不到ID为 ${id} 的家居装饰`)
date: "2023-09-01", } finally {
quantity: 1500, setLoading(false)
startId: "DEC001-0001", }
endId: "DEC001-1500", }
activatedCount: 842, fetchDecor()
}, }, [id])
{
id: "B002", if (loading) {
date: "2023-12-15", return (
quantity: 1000, <DashboardShell>
startId: "DEC001-1501", <div className="flex items-center justify-center h-[60vh]">
endId: "DEC001-2500", <Loader2 className="h-8 w-8 animate-spin text-pink-500" />
activatedCount: 500, <span className="ml-2 text-muted-foreground">...</span>
}, </div>
], </DashboardShell>
}, )
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: { id: string } }) { if (error || !decor) {
const decor = decorData[params.id as keyof typeof decorData]
if (!decor) {
return ( return (
<DashboardShell> <DashboardShell>
<div className="flex flex-col items-center justify-center h-[60vh]"> <div className="flex flex-col items-center justify-center h-[60vh]">
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" /> <AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-2"></h1> <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> <Button asChild>
<Link href="/home-decor"> <Link href="/home-decor">
<ArrowLeft className="mr-2 h-4 w-4" /> <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 isPublished = decor.status === "已发布"
const printedCount = decor.batchesCount || 0
const activatedCount = decor.activeCardsCount || 0
const activationRate = printedCount > 0 ? Math.round((activatedCount / printedCount) * 100) : 0
return ( return (
<DashboardShell> <DashboardShell>
@ -167,12 +85,12 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
</Button> </Button>
<DashboardHeader heading={decor.name} text={`家居装饰ID: ${decor.id}`}> <DashboardHeader heading={decor.name} text={`家居装饰ID: ${decor.id}`}>
<div className="flex space-x-2 ml-auto"> <div className="flex space-x-2 ml-auto">
{!isPublished && ( {(!isPublished || isSuperUser()) && (
<Button <Button
asChild 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" 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" /> <Edit className="mr-2 h-4 w-4" />
</Link> </Link>
@ -183,14 +101,14 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
</DashboardHeader> </DashboardHeader>
</div> </div>
<Tabs defaultValue="details" className="space-y-4"> <Tabs defaultValue="details" className="w-full">
<TabsList> <TabsList className="grid w-full md:w-auto grid-cols-2 md:grid-cols-3 mb-4">
<TabsTrigger value="details"></TabsTrigger> <TabsTrigger value="details"></TabsTrigger>
<TabsTrigger value="batches"></TabsTrigger> <TabsTrigger value="batches"></TabsTrigger>
<TabsTrigger value="analytics"></TabsTrigger> <TabsTrigger value="analytics"></TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="details" className="space-y-4"> <TabsContent value="details" className="space-y-6">
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
<Card className="md:col-span-1 border-none shadow-lg bg-white"> <Card className="md:col-span-1 border-none shadow-lg bg-white">
<CardHeader> <CardHeader>
@ -198,9 +116,18 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
</CardHeader> </CardHeader>
<CardContent className="flex justify-center"> <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"> <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"> <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>
</div> </div>
</CardContent> </CardContent>
@ -213,39 +140,44 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.type}</p> <p className="font-medium">{decor.category || "-"}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.releaseDate || "尚未发布"}</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.status}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.printedCount}</p> <p className="font-medium">{decor.createdAt || "-"}</p>
</div> </div>
<div className="col-span-2 space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{decor.description}</p> <p className="font-medium">{activationRate}%</p>
</div> </div>
</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 && ( {isPublished && (
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start"> <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" /> <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> </div>
)} )}
</CardContent> </CardContent>
@ -253,11 +185,11 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
</div> </div>
</TabsContent> </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"> <Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle className="text-lg font-bold"></CardTitle> <CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>ID</CardDescription> <CardDescription>ID</CardDescription>
</div> </div>
<AddPrintBatchDialog decorId={decor.id} isPublished={isPublished} /> <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">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"></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"></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> <th className="py-3 px-4 text-right text-sm font-medium text-gray-500"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{decor.batches.map((batch) => ( <tr>
<tr key={batch.id} className="border-b hover:bg-gray-50"> <td colSpan={5} className="py-8 text-center text-gray-500">
<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>
</td> </td>
</tr> </tr>
))}
</tbody> </tbody>
</table> </table>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
<TabsContent value="analytics" className="space-y-4"> <Card className="border-none shadow-lg bg-white">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-green-50">
<CardHeader> <CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle> <CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>使</CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-6 md:grid-cols-2"> <div className="flex flex-wrap gap-4">
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center"> <Button variant="outline" className="border-blue-200 hover:bg-blue-50 hover:text-blue-700">
<p className="text-gray-500"></p> <Download className="mr-2 h-4 w-4" />
</div>
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center"> </Button>
<p className="text-gray-500"></p> <Button variant="outline" className="border-purple-200 hover:bg-purple-50 hover:text-purple-700">
</div> <Plus className="mr-2 h-4 w-4" />
<div className="md:col-span-2 h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500"></p> </Button>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </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> </Tabs>
</DashboardShell> </DashboardShell>
) )

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect, useCallback } from "react"
import { DashboardShell } from "@/components/dashboard-shell" import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header" import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -8,96 +8,90 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge" 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 { 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 { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
import { useToast } from "@/components/ui/use-toast" 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 Link from "next/link"
import type { HomeDecor } from "@/components/home-decor/home-decor-detail-dialog"
// 初始家居装饰数据 // 格式化日期时间
const initialDecors: HomeDecor[] = [ function formatDate(dateStr: string): string {
{ if (!dateStr) return ""
id: "DEC001", try {
name: "星空投影灯", return new Date(dateStr).toLocaleDateString("zh-CN", {
type: "灯饰", year: "numeric",
rarity: "稀有", month: "2-digit",
description: "可以在房间内投影出美丽的星空,营造浪漫氛围。", day: "2-digit",
releaseDate: "2023-10-20", })
status: "已发布", } catch {
activatedCount: 1342, return dateStr
image: "/placeholder.svg?height=300&width=300", }
}, }
{
id: "DEC002", // 将 API HomeDecor 转换为组件显示用的 HomeDecor
name: "音乐主题壁纸", function toDisplayDecor(decor: HomeDecor): ComponentHomeDecor {
type: "墙饰", return {
rarity: "普通", id: decor.id,
description: "以音乐元素为主题的壁纸,适合洛天依的房间装饰。", name: decor.name,
releaseDate: "2023-11-05", type: decor.category || "",
status: "已发布", rarity: decor.rarity || "",
activatedCount: 2156, description: decor.description || "",
image: "/placeholder.svg?height=300&width=300", releaseDate: formatDate(decor.publishedAt || decor.createdAt || ""),
}, status: decor.status || "未发布",
{ activatedCount: decor.activeCardsCount || 0,
id: "DEC003", image: decor.imageUrl || "/placeholder.svg?height=300&width=300",
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",
},
]
export default function HomeDecorPage() { export default function HomeDecorPage() {
const { toast } = useToast() const { toast } = useToast()
const [decors, setDecors] = useState<HomeDecor[]>(initialDecors) const [decors, setDecors] = useState<ComponentHomeDecor[]>([])
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState("")
const [currentPage, setCurrentPage] = useState(1) 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 [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [loading, setLoading] = useState(true)
const itemsPerPage = 5 const itemsPerPage = 10
// 过滤和分页 // 从后端获取家居装饰列表
const filteredDecors = decors.filter( const fetchDecors = useCallback(async () => {
(decor) => try {
decor.name.toLowerCase().includes(searchTerm.toLowerCase()) || setLoading(true)
decor.id.toLowerCase().includes(searchTerm.toLowerCase()) || const response = await getHomeDecors({
decor.type.toLowerCase().includes(searchTerm.toLowerCase()), 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) useEffect(() => {
const paginatedDecors = filteredDecors.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage) fetchDecors()
}, [fetchDecors])
const totalPages = Math.ceil(totalItems / itemsPerPage)
// 处理添加家居装饰 // 处理添加家居装饰
const handleAddDecor = (newDecor: HomeDecor) => { const handleAddDecor = (newDecor: ComponentHomeDecor) => {
setDecors((prevDecors) => [...prevDecors, newDecor]) fetchDecors()
toast({ toast({
title: "添加成功", title: "添加成功",
description: `家居装饰 ${newDecor.name} 已成功添加`, description: `家居装饰 ${newDecor.name} 已成功添加`,
@ -105,8 +99,8 @@ export default function HomeDecorPage() {
} }
// 处理编辑家居装饰 // 处理编辑家居装饰
const handleEditDecor = (updatedDecor: HomeDecor) => { const handleEditDecor = (updatedDecor: ComponentHomeDecor) => {
setDecors((prevDecors) => prevDecors.map((decor) => (decor.id === updatedDecor.id ? updatedDecor : decor))) fetchDecors()
setSelectedDecor(null) setSelectedDecor(null)
setIsEditDialogOpen(false) setIsEditDialogOpen(false)
toast({ toast({
@ -117,19 +111,64 @@ export default function HomeDecorPage() {
// 处理删除家居装饰 // 处理删除家居装饰
const handleDeleteDecor = async (decorId: string) => { const handleDeleteDecor = async (decorId: string) => {
// 模拟API请求 try {
await new Promise((resolve) => setTimeout(resolve, 1000)) await deleteHomeDecor(decorId)
await fetchDecors()
setDecors((prevDecors) => prevDecors.filter((decor) => decor.id !== decorId))
toast({ toast({
title: "删除成功", title: "删除成功",
description: "家居装饰已成功删除", description: "家居装饰已成功删除",
variant: "destructive", 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) setSelectedDecor(decor)
setIsEditDialogOpen(true) setIsEditDialogOpen(true)
} }
@ -151,7 +190,7 @@ export default function HomeDecorPage() {
value={searchTerm} value={searchTerm}
onChange={(e) => { onChange={(e) => {
setSearchTerm(e.target.value) setSearchTerm(e.target.value)
setCurrentPage(1) // 重置到第一页 setCurrentPage(1)
}} }}
/> />
</div> </div>
@ -161,14 +200,18 @@ export default function HomeDecorPage() {
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50"> <Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-bold flex items-center"> <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 className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600"></span>
</span>
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div> <div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
</CardTitle> </CardTitle>
<CardDescription></CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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> <Table>
<TableHeader className="bg-gray-50"> <TableHeader className="bg-gray-50">
<TableRow> <TableRow>
@ -183,7 +226,7 @@ export default function HomeDecorPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{paginatedDecors.map((decor) => ( {decors.map((decor) => (
<TableRow key={decor.id} className="hover:bg-gray-50 transition-colors"> <TableRow key={decor.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">{decor.id}</TableCell> <TableCell className="font-medium">{decor.id}</TableCell>
<TableCell className="font-medium text-pink-600">{decor.name}</TableCell> <TableCell className="font-medium text-pink-600">{decor.name}</TableCell>
@ -193,7 +236,11 @@ export default function HomeDecorPage() {
<TableCell> <TableCell>
<Badge <Badge
className={ 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} {decor.status}
@ -208,7 +255,30 @@ export default function HomeDecorPage() {
</Link> </Link>
</Button> </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 <Button
variant="ghost" variant="ghost"
@ -231,7 +301,7 @@ export default function HomeDecorPage() {
</TableRow> </TableRow>
))} ))}
{paginatedDecors.length === 0 && ( {decors.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={8} className="h-24 text-center"> <TableCell colSpan={8} className="h-24 text-center">
@ -240,11 +310,12 @@ export default function HomeDecorPage() {
)} )}
</TableBody> </TableBody>
</Table> </Table>
)}
</CardContent> </CardContent>
<CardFooter className="flex justify-between"> <CardFooter className="flex justify-between">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{paginatedDecors.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}- {decors.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
{Math.min(currentPage * itemsPerPage, filteredDecors.length)} {filteredDecors.length} {Math.min(currentPage * itemsPerPage, totalItems)} {totalItems}
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button

View File

@ -35,8 +35,8 @@ export default function LoginPage() {
const response = await emailLogin(email, password) const response = await emailLogin(email, password)
console.log(response) console.log(response)
// 保存登录凭证 // 保存登录凭证(包含角色信息)
saveAuthToken(response.data.token) saveAuthToken(response.data.token, response.data.is_superuser, response.data.role)
// 设置登录状态 // 设置登录状态
localStorage.setItem("isLoggedIn", "true") 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 { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header" import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" 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 Link from "next/link"
import { AddPrintBatchDialog } from "@/components/outfits/add-print-batch-dialog" import { AddPrintBatchDialog } from "@/components/outfits/add-print-batch-dialog"
import { ExportCardsDialog } from "@/components/outfits/export-cards-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 export default function OutfitDetailPage({ params }: { params: Promise<{ id: string }> }) {
const outfitData = { const { id } = use(params)
OFT001: { const [outfit, setOutfit] = useState<Outfit | null>(null)
id: "OFT001", const [loading, setLoading] = useState(true)
name: "经典原创服装", const [error, setError] = useState<string | null>(null)
type: "常规",
rarity: "稀有", useEffect(() => {
description: "洛天依的经典原创服装,简约而不失时尚,适合各种场合穿着。", const fetchOutfit = async () => {
releaseDate: "2023-10-15", try {
status: "已发布", setLoading(true)
activatedCount: 1245, setError(null)
printedCount: 2000, const data = await getOutfit(id)
image: "/placeholder.svg?height=300&width=300", setOutfit(data)
batches: [ } catch (err) {
{ id: "B001", date: "2023-09-01", quantity: 1000, startId: "OFT001-0001", endId: "OFT001-1000" }, console.error("获取服装详情失败:", err)
{ id: "B002", date: "2023-12-15", quantity: 1000, startId: "OFT001-1001", endId: "OFT001-2000" }, setError(`找不到ID为 ${id} 的服装`)
], } finally {
}, setLoading(false)
OFT002: { }
id: "OFT002", }
name: "夏日泳装", fetchOutfit()
type: "季节限定", }, [id])
rarity: "史诗",
description: "专为夏季设计的清凉泳装,让洛天依在夏日活动中更加亮眼。", if (loading) {
releaseDate: "2023-06-01", return (
status: "已发布", <DashboardShell>
activatedCount: 876, <div className="flex items-center justify-center h-[60vh]">
printedCount: 1500, <Loader2 className="h-8 w-8 animate-spin text-pink-500" />
image: "/placeholder.svg?height=300&width=300", <span className="ml-2 text-muted-foreground">...</span>
batches: [{ id: "B003", date: "2023-05-10", quantity: 1500, startId: "OFT002-0001", endId: "OFT002-1500" }], </div>
}, </DashboardShell>
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: { id: string } }) { if (error || !outfit) {
const outfit = outfitData[params.id as keyof typeof outfitData]
if (!outfit) {
return ( return (
<DashboardShell> <DashboardShell>
<div className="flex flex-col items-center justify-center h-[60vh]"> <div className="flex flex-col items-center justify-center h-[60vh]">
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" /> <AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-2"></h1> <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> <Button asChild>
<Link href="/outfits"> <Link href="/outfits">
<ArrowLeft className="mr-2 h-4 w-4" /> <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 isPublished = outfit.status === "已发布"
const printedCount = outfit.batchesCount || 0
const activatedCount = outfit.activeCardsCount || 0
const activationRate = printedCount > 0 ? Math.round((activatedCount / printedCount) * 100) : 0
return ( return (
<DashboardShell> <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"> <div className="flex items-center mb-6">
<Button variant="ghost" size="sm" className="mr-4" asChild> <Button variant="ghost" size="sm" className="mr-4" asChild>
@ -120,12 +85,12 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
</Button> </Button>
<DashboardHeader heading={outfit.name} text={`服装ID: ${outfit.id}`}> <DashboardHeader heading={outfit.name} text={`服装ID: ${outfit.id}`}>
<div className="flex space-x-2 ml-auto"> <div className="flex space-x-2 ml-auto">
{!isPublished && ( {(!isPublished || isSuperUser()) && (
<Button <Button
asChild 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" 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" /> <Edit className="mr-2 h-4 w-4" />
</Link> </Link>
@ -136,14 +101,14 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
</DashboardHeader> </DashboardHeader>
</div> </div>
<Tabs defaultValue="details" className="space-y-4"> <Tabs defaultValue="details" className="w-full">
<TabsList> <TabsList className="grid w-full md:w-auto grid-cols-2 md:grid-cols-3 mb-4">
<TabsTrigger value="details"></TabsTrigger> <TabsTrigger value="details"></TabsTrigger>
<TabsTrigger value="batches"></TabsTrigger> <TabsTrigger value="batches"></TabsTrigger>
<TabsTrigger value="analytics"></TabsTrigger> <TabsTrigger value="analytics"></TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="details" className="space-y-4"> <TabsContent value="details" className="space-y-6">
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
<Card className="md:col-span-1 border-none shadow-lg bg-white"> <Card className="md:col-span-1 border-none shadow-lg bg-white">
<CardHeader> <CardHeader>
@ -151,9 +116,18 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
</CardHeader> </CardHeader>
<CardContent className="flex justify-center"> <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"> <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"> <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>
</div> </div>
</CardContent> </CardContent>
@ -167,38 +141,43 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{outfit.printedCount}</p> <p className="font-medium">{outfit.createdAt || "-"}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{outfit.printedCount - outfit.activatedCount}</p> <p className="font-medium">{activationRate}%</p>
</div> </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>
<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> </div>
{isPublished && ( {isPublished && (
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start"> <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" /> <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> </div>
)} )}
</CardContent> </CardContent>
@ -206,11 +185,11 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
</div> </div>
</TabsContent> </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"> <Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle className="text-lg font-bold"></CardTitle> <CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>ID</CardDescription> <CardDescription>ID</CardDescription>
</div> </div>
<AddPrintBatchDialog outfitId={outfit.id} isPublished={isPublished} /> <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">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"></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"></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-right 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> </tr>
</thead> </thead>
<tbody> <tbody>
{outfit.batches.map((batch) => ( <tr>
<tr key={batch.id} className="border-b hover:bg-gray-50"> <td colSpan={5} className="py-8 text-center text-gray-500">
<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>
</td> </td>
</tr> </tr>
))}
</tbody> </tbody>
</table> </table>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
<TabsContent value="analytics" className="space-y-4"> <Card className="border-none shadow-lg bg-white">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-green-50">
<CardHeader> <CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle> <CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>使</CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-6 md:grid-cols-2"> <div className="flex flex-wrap gap-4">
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center"> <Button variant="outline" className="border-blue-200 hover:bg-blue-50 hover:text-blue-700">
<p className="text-gray-500"></p> <Download className="mr-2 h-4 w-4" />
</div>
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center"> </Button>
<p className="text-gray-500"></p> <Button variant="outline" className="border-purple-200 hover:bg-purple-50 hover:text-purple-700">
</div> <Plus className="mr-2 h-4 w-4" />
<div className="md:col-span-2 h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500"></p> </Button>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </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> </Tabs>
</DashboardShell> </DashboardShell>
) )

View File

@ -1,166 +1,173 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect, useCallback } from "react"
import { DashboardShell } from "@/components/dashboard-shell" import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header" import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge" 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 Link from "next/link"
import {
Dialog, function formatDate(dateStr: string): string {
DialogContent, if (!dateStr) return ""
DialogDescription, try {
DialogFooter, return new Date(dateStr).toLocaleDateString("zh-CN", {
DialogHeader, year: "numeric",
DialogTitle, month: "2-digit",
DialogTrigger, day: "2-digit",
} from "@/components/ui/dialog" })
import { Label } from "@/components/ui/label" } catch {
import { Textarea } from "@/components/ui/textarea" return dateStr
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" }
}
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() { export default function OutfitsPage() {
// 直接在页面中实现对话框 const { toast } = useToast()
const [open, setOpen] = useState(false) const [outfits, setOutfits] = useState<DisplayOutfit[]>([])
const [step, setStep] = useState(1) const [searchTerm, setSearchTerm] = useState("")
const [outfitType, setOutfitType] = useState("") const [currentPage, setCurrentPage] = useState(1)
const [rarity, setRarity] = useState("") const [totalItems, setTotalItems] = useState(0)
const [printQuantity, setPrintQuantity] = useState(1000) const [selectedOutfit, setSelectedOutfit] = useState<DisplayOutfit | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [loading, setLoading] = useState(true)
const handleSubmit = async () => { const itemsPerPage = 10
setIsSubmitting(true)
// 模拟API请求 const fetchOutfits = useCallback(async () => {
await new Promise((resolve) => setTimeout(resolve, 1500)) try {
setIsSubmitting(false) setLoading(true)
setOpen(false) const response = await getOutfits({
// 重置表单 page: currentPage,
setStep(1) 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 = () => { const handleEditOutfit = (updatedOutfit: DisplayOutfit) => {
setStep(step + 1) fetchOutfits()
setSelectedOutfit(null)
setIsEditDialogOpen(false)
toast({
title: "更新成功",
description: `服装 ${updatedOutfit.name} 已成功更新`,
})
} }
const handleBack = () => { const handleDeleteOutfit = async (outfitId: string) => {
setStep(step - 1) 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 ( return (
<DashboardShell> <DashboardShell>
<DashboardHeader heading="服装管理" text="管理洛天依的服装卡牌"> <DashboardHeader heading="服装管理" text="管理洛天依的服装卡牌">
<Dialog open={open} onOpenChange={setOpen}> <AddOutfitDialog onSave={handleAddOutfit} />
<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>
</DashboardHeader> </DashboardHeader>
<div className="flex items-center justify-between space-y-2 mb-6"> <div className="flex items-center justify-between space-y-2 mb-6">
@ -171,172 +178,141 @@ export default function OutfitsPage() {
type="search" type="search"
placeholder="搜索服装..." placeholder="搜索服装..."
className="w-[300px] pl-8 border-none bg-white shadow-md focus-visible:ring-pink-500" 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> </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"> <Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-bold flex items-center"> <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 className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600"></span>
</span>
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div> <div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
</CardTitle> </CardTitle>
<CardDescription></CardDescription> <CardDescription>使</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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> <Table>
<TableHeader className="bg-gray-50"> <TableHeader className="bg-gray-50">
<TableRow> <TableRow>
<TableHead className="w-[100px]">ID</TableHead> <TableHead className="w-[100px]">ID</TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow className="hover:bg-gray-50 transition-colors"> {outfits.map((outfit) => (
<TableCell className="font-medium">OFT001</TableCell> <TableRow key={outfit.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium text-pink-600"></TableCell> <TableCell className="font-medium">{outfit.id}</TableCell>
<TableCell></TableCell> <TableCell className="font-medium text-pink-600">{outfit.name}</TableCell>
<TableCell>2023-10-15</TableCell> <TableCell>{outfit.type}</TableCell>
<TableCell>{outfit.rarity}</TableCell>
<TableCell>{outfit.releaseDate || "-"}</TableCell>
<TableCell> <TableCell>
<Badge className="bg-green-500 hover:bg-green-600"></Badge> <Badge
</TableCell> className={
<TableCell className="font-medium">1,245</TableCell> outfit.status === "已发布"
<TableCell className="font-medium">2,000</TableCell> ? "bg-green-500 hover:bg-green-600"
<TableCell className="text-right"> : outfit.status === "已归档"
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild> ? "bg-orange-500 hover:bg-orange-600"
<Link href="/outfits/OFT001"> : "bg-gray-500 hover:bg-gray-600"
<Eye className="h-4 w-4" /> }
</Link> >
</Button> {outfit.status}
</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> </Badge>
</TableCell> </TableCell>
<TableCell className="font-medium">0</TableCell> <TableCell className="font-medium">{outfit.activatedCount}</TableCell>
<TableCell className="font-medium">1,000</TableCell>
<TableCell className="text-right"> <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> <Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
<Link href="/outfits/edit/OFT005"> <Link href={`/outfits/${outfit.id}`}>
<Edit className="h-4 w-4" /> <Eye className="h-4 w-4" />
<span className="sr-only"></span>
</Link> </Link>
</Button> </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> </TableCell>
</TableRow> </TableRow>
))}
{outfits.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
</TableCell>
</TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
)}
</CardContent> </CardContent>
<CardFooter className="flex justify-between"> <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"> <div className="flex items-center space-x-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200" 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> </Button>
@ -344,145 +320,24 @@ export default function OutfitsPage() {
variant="outline" variant="outline"
size="sm" size="sm"
className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200" 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> </Button>
</div> </div>
</CardFooter> </CardFooter>
</Card> </Card>
</TabsContent>
<TabsContent value="published" className="space-y-4"> {selectedOutfit && isEditDialogOpen && (
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50"> <AddOutfitDialog
<CardHeader> mode="edit"
<CardTitle className="text-xl font-bold flex items-center"> initialOutfit={selectedOutfit}
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600"> open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
</span> onSave={handleEditOutfit}
<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>
</DashboardShell> </DashboardShell>
) )
} }

View File

@ -130,6 +130,9 @@ const initialRoles: Role[] = [
"homeDecor.view": true, "homeDecor.view": true,
"homeDecor.create": true, "homeDecor.create": true,
"homeDecor.edit": true, "homeDecor.edit": true,
"food.view": true,
"food.create": true,
"food.edit": true,
}, },
}, },
{ {
@ -141,16 +144,6 @@ const initialRoles: Role[] = [
status: "自定义角色", status: "自定义角色",
permissions: { permissions: {
"dashboard.view": true, "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 { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header" import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" 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 Link from "next/link"
import { AddPrintBatchDialog } from "@/components/props/add-print-batch-dialog" import { AddPrintBatchDialog } from "@/components/props/add-print-batch-dialog"
import { ExportCardsDialog } from "@/components/props/export-cards-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 export default function PropDetailPage({ params }: { params: Promise<{ id: string }> }) {
const propData = { const { id } = use(params)
PRP001: { const [prop, setProp] = useState<Prop | null>(null)
id: "PRP001", const [loading, setLoading] = useState(true)
name: "魔法麦克风", const [error, setError] = useState<string | null>(null)
type: "演出道具",
rarity: "稀有", useEffect(() => {
description: "洛天依的经典原创道具,可以增强歌声的魔力,让听众更加沉浸在音乐中。", const fetchProp = async () => {
releaseDate: "2023-11-15", try {
status: "已发布", setLoading(true)
activatedCount: 1245, setError(null)
printedCount: 2000, const data = await getProp(id)
image: "/placeholder.svg?height=300&width=300", setProp(data)
batches: [ } catch (err) {
{ console.error("获取道具详情失败:", err)
id: "B001", setError(`找不到ID为 ${id} 的道具`)
date: "2023-09-01", } finally {
quantity: 1000, setLoading(false)
startId: "PRP001-0001", }
endId: "PRP001-1000", }
status: "已激活", fetchProp()
activatedCount: 980, }, [id])
},
{ if (loading) {
id: "B002", return (
date: "2023-12-15", <DashboardShell>
quantity: 1000, <div className="flex items-center justify-center h-[60vh]">
startId: "PRP001-1001", <Loader2 className="h-8 w-8 animate-spin text-pink-500" />
endId: "PRP001-2000", <span className="ml-2 text-muted-foreground">...</span>
status: "已激活", </div>
activatedCount: 265, </DashboardShell>
}, )
],
},
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: { id: string } }) { if (error || !prop) {
const prop = propData[params.id as keyof typeof propData]
if (!prop) {
return ( return (
<DashboardShell> <DashboardShell>
<div className="flex flex-col items-center justify-center h-[60vh]"> <div className="flex flex-col items-center justify-center h-[60vh]">
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" /> <AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-2"></h1> <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> <Button asChild>
<Link href="/props"> <Link href="/props">
<ArrowLeft className="mr-2 h-4 w-4" /> <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 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 ( return (
<DashboardShell> <DashboardShell>
@ -174,12 +85,12 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
</Button> </Button>
<DashboardHeader heading={prop.name} text={`道具ID: ${prop.id}`}> <DashboardHeader heading={prop.name} text={`道具ID: ${prop.id}`}>
<div className="flex space-x-2 ml-auto"> <div className="flex space-x-2 ml-auto">
{!isPublished && ( {(!isPublished || isSuperUser()) && (
<Button <Button
asChild 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" 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" /> <Edit className="mr-2 h-4 w-4" />
</Link> </Link>
@ -205,9 +116,18 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
</CardHeader> </CardHeader>
<CardContent className="flex justify-center"> <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"> <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"> <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>
</div> </div>
</CardContent> </CardContent>
@ -221,23 +141,23 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{prop.printedCount}</p> <p className="font-medium">{prop.createdAt || "-"}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p> <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"> <div className="mt-6">
<p className="text-sm font-medium text-gray-500 mb-2"></p> <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> </div>
{isPublished && ( {isPublished && (
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start"> <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" /> <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> </div>
)} )}
</CardContent> </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">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"></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-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> <th className="py-3 px-4 text-right text-sm font-medium text-gray-500"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{prop.batches.map((batch) => ( <tr>
<tr key={batch.id} className="border-b hover:bg-gray-50"> <td colSpan={5} className="py-8 text-center text-gray-500">
<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>
</td> </td>
</tr> </tr>
))}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect, useCallback } from "react"
import { DashboardShell } from "@/components/dashboard-shell" import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header" import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -8,96 +8,91 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge" 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 { 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 { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
import { useToast } from "@/components/ui/use-toast" 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" import Link from "next/link"
// 初始道具数据 // 格式化日期时间
const initialProps: Prop[] = [ function formatDate(dateStr: string): string {
{ if (!dateStr) return ""
id: "PRP001", try {
name: "魔法麦克风", return new Date(dateStr).toLocaleDateString("zh-CN", {
type: "演出道具", year: "numeric",
rarity: "稀有", month: "2-digit",
description: "洛天依的经典原创道具,可以增强歌声的魔力,让听众更加沉浸在音乐中。", day: "2-digit",
releaseDate: "2023-11-15", })
status: "已发布", } catch {
activatedCount: 1245, return dateStr
image: "/placeholder.svg?height=300&width=300", }
}, }
{
id: "PRP002", // 将 API Prop 转换为组件显示用的 Prop
name: "星光魔杖", function toDisplayProp(prop: Prop): ComponentProp {
type: "互动道具", return {
rarity: "史诗", id: prop.id,
description: "挥舞魔杖可以创造出美丽的星光效果,增加互动时的好感度。", name: prop.name,
releaseDate: "2023-12-01", type: prop.category || "",
status: "已发布", rarity: prop.rarity || "",
activatedCount: 876, description: prop.description || "",
image: "/placeholder.svg?height=300&width=300", releaseDate: formatDate(prop.publishedAt || prop.createdAt || ""),
}, status: prop.status || "未发布",
{ activatedCount: prop.activeCardsCount || 0,
id: "PRP003", image: prop.imageUrl || "/placeholder.svg?height=300&width=300",
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",
},
]
export default function PropsPage() { export default function PropsPage() {
const { toast } = useToast() const { toast } = useToast()
const [props, setProps] = useState<Prop[]>(initialProps) const [props, setProps] = useState<ComponentProp[]>([])
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState("")
const [currentPage, setCurrentPage] = useState(1) 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 [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [loading, setLoading] = useState(true)
const itemsPerPage = 5 const itemsPerPage = 10
// 过滤和分页 // 从后端获取道具列表
const filteredProps = props.filter( const fetchProps = useCallback(async () => {
(prop) => try {
prop.name.toLowerCase().includes(searchTerm.toLowerCase()) || setLoading(true)
prop.id.toLowerCase().includes(searchTerm.toLowerCase()) || const response = await getProps({
prop.type.toLowerCase().includes(searchTerm.toLowerCase()), 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) useEffect(() => {
const paginatedProps = filteredProps.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage) fetchProps()
}, [fetchProps])
const totalPages = Math.ceil(totalItems / itemsPerPage)
// 处理添加道具 // 处理添加道具
const handleAddProp = (newProp: Prop) => { const handleAddProp = (newProp: ComponentProp) => {
setProps((prevProps) => [...prevProps, newProp]) // 添加后刷新列表
fetchProps()
toast({ toast({
title: "添加成功", title: "添加成功",
description: `道具 ${newProp.name} 已成功添加`, description: `道具 ${newProp.name} 已成功添加`,
@ -105,8 +100,9 @@ export default function PropsPage() {
} }
// 处理编辑道具 // 处理编辑道具
const handleEditProp = (updatedProp: Prop) => { const handleEditProp = (updatedProp: ComponentProp) => {
setProps((prevProps) => prevProps.map((prop) => (prop.id === updatedProp.id ? updatedProp : prop))) // 编辑后刷新列表
fetchProps()
setSelectedProp(null) setSelectedProp(null)
setIsEditDialogOpen(false) setIsEditDialogOpen(false)
toast({ toast({
@ -115,21 +111,67 @@ export default function PropsPage() {
}) })
} }
// 处理删除道具 // 处理删除道具 - 调用真实的后端 API
const handleDeleteProp = async (propId: string) => { const handleDeleteProp = async (propId: string) => {
// 模拟API请求 try {
await new Promise((resolve) => setTimeout(resolve, 1000)) await deleteProp(propId)
// 删除后刷新列表
setProps((prevProps) => prevProps.filter((prop) => prop.id !== propId)) await fetchProps()
toast({ toast({
title: "删除成功", title: "删除成功",
description: "道具已成功删除", description: "道具已成功删除",
variant: "destructive", 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) setSelectedProp(prop)
setIsEditDialogOpen(true) setIsEditDialogOpen(true)
} }
@ -151,7 +193,7 @@ export default function PropsPage() {
value={searchTerm} value={searchTerm}
onChange={(e) => { onChange={(e) => {
setSearchTerm(e.target.value) setSearchTerm(e.target.value)
setCurrentPage(1) // 重置到第一页 setCurrentPage(1)
}} }}
/> />
</div> </div>
@ -167,6 +209,12 @@ export default function PropsPage() {
<CardDescription>使</CardDescription> <CardDescription>使</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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> <Table>
<TableHeader className="bg-gray-50"> <TableHeader className="bg-gray-50">
<TableRow> <TableRow>
@ -181,7 +229,7 @@ export default function PropsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{paginatedProps.map((prop) => ( {props.map((prop) => (
<TableRow key={prop.id} className="hover:bg-gray-50 transition-colors"> <TableRow key={prop.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">{prop.id}</TableCell> <TableCell className="font-medium">{prop.id}</TableCell>
<TableCell className="font-medium text-pink-600">{prop.name}</TableCell> <TableCell className="font-medium text-pink-600">{prop.name}</TableCell>
@ -191,7 +239,11 @@ export default function PropsPage() {
<TableCell> <TableCell>
<Badge <Badge
className={ 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} {prop.status}
@ -199,7 +251,6 @@ export default function PropsPage() {
</TableCell> </TableCell>
<TableCell className="font-medium">{prop.activatedCount}</TableCell> <TableCell className="font-medium">{prop.activatedCount}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{/* 将详情对话框替换为链接按钮 */}
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild> <Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
<Link href={`/props/${prop.id}`}> <Link href={`/props/${prop.id}`}>
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
@ -207,7 +258,30 @@ export default function PropsPage() {
</Link> </Link>
</Button> </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 <Button
variant="ghost" variant="ghost"
@ -230,7 +304,7 @@ export default function PropsPage() {
</TableRow> </TableRow>
))} ))}
{paginatedProps.length === 0 && ( {props.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={8} className="h-24 text-center"> <TableCell colSpan={8} className="h-24 text-center">
@ -239,11 +313,12 @@ export default function PropsPage() {
)} )}
</TableBody> </TableBody>
</Table> </Table>
)}
</CardContent> </CardContent>
<CardFooter className="flex justify-between"> <CardFooter className="flex justify-between">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{paginatedProps.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}- {props.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
{Math.min(currentPage * itemsPerPage, filteredProps.length)} {filteredProps.length} {Math.min(currentPage * itemsPerPage, totalItems)} {totalItems}
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button

View File

@ -18,7 +18,8 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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 { interface AddDanceDialogProps {
open: boolean open: boolean
@ -42,6 +43,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
tags: [], tags: [],
motionFile: "", motionFile: "",
videoUrl: "", videoUrl: "",
coverUrl: "",
}) })
// 预览ID // 预览ID
@ -65,6 +67,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
tags: editDance.tags || [], tags: editDance.tags || [],
motionFile: editDance.motionFile || "", motionFile: editDance.motionFile || "",
videoUrl: editDance.videoUrl || "", videoUrl: editDance.videoUrl || "",
coverUrl: editDance.coverUrl || "",
}) })
} else { } else {
// 重置表单 // 重置表单
@ -78,6 +81,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
tags: [], tags: [],
motionFile: "", motionFile: "",
videoUrl: "", videoUrl: "",
coverUrl: "",
}) })
// 生成新的预览ID // 生成新的预览ID
setPreviewId( setPreviewId(
@ -137,7 +141,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
tags: formData.tags || [], tags: formData.tags || [],
motionFile: formData.motionFile || "", motionFile: formData.motionFile || "",
videoUrl: formData.videoUrl || "", 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(), createdAt: editDance?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
@ -298,20 +302,82 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-right"></Label> <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"> {formData.coverUrl && !formData.coverUrl.includes('placeholder') ? (
<Video className="h-8 w-8 text-gray-400 mb-2" /> <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<p className="text-sm text-gray-500"></p> <div className="flex items-center space-x-3">
<p className="text-xs text-gray-400 mt-1"> PNG, JPG, JPEG 5MB</p> <img
src={formData.coverUrl}
alt="舞蹈封面"
className="h-16 w-16 object-cover rounded border"
/>
<span className="text-sm text-gray-700"></span>
</div> </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>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-right"></Label> <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"> {formData.motionFile ? (
<Upload className="h-8 w-8 text-gray-400 mb-2" /> <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<p className="text-sm text-gray-500"></p> <div className="flex items-center space-x-3">
<p className="text-xs text-gray-400 mt-1"> FBX, BVH 20MB</p> <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> </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> </div>
{!editDance && ( {!editDance && (

View File

@ -1,7 +1,7 @@
import type React from "react" import type React from "react"
interface DashboardHeaderProps { interface DashboardHeaderProps {
heading: string heading: React.ReactNode
text?: string text?: React.ReactNode
children?: 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"> <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} {heading}
</h1> </h1>
{text && <p className="text-lg text-muted-foreground">{text}</p>} {text && <div className="text-lg text-muted-foreground">{text}</div>}
</div> </div>
{children} {children}
</div> </div>

View File

@ -1,15 +1,48 @@
"use client"
import type React from "react" import type React from "react"
import { useEffect, useState } from "react"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Sidebar } from "@/components/sidebar" 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> {} interface DashboardShellProps extends React.HTMLAttributes<HTMLDivElement> {}
export function DashboardShell({ children, className, ...props }: DashboardShellProps) { 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 ( return (
<div className="grid min-h-screen w-full md:grid-cols-[280px_1fr] bg-gradient-to-br from-gray-50 to-white"> <div className="grid min-h-screen w-full md:grid-cols-[280px_1fr] bg-gradient-to-br from-gray-50 to-white">
<Sidebar /> <Sidebar />
<main className={cn("flex flex-col relative overflow-hidden", className)} {...props}> <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> </main>
</div> </div>
) )

View File

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

View File

@ -18,8 +18,10 @@ export type Food = {
id: string id: string
name: string name: string
food_type: string food_type: string
food_type_display?: string
description: string description: string
rarity: string rarity: string
rarity_display?: string
image?: string image?: string
animation_file?: string animation_file?: string
sound_effect?: string sound_effect?: string
@ -28,6 +30,7 @@ export type Food = {
nutritional_value?: string nutritional_value?: string
effect_description?: string effect_description?: string
boost_attributes?: Record<string, number> boost_attributes?: Record<string, number>
is_limited?: boolean
status: string status: string
created_at?: string created_at?: string
updated_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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Image, Play, Music, Trash2 } from 'lucide-react' import { Image, Play, Music, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { UploadResponse } from '@/lib/api/upload'
interface FoodMediaUploadProps { interface FoodMediaUploadProps {
/** 当前图片URL */ /** 当前图片URL */
@ -39,18 +38,6 @@ export function FoodMediaUpload({
}: FoodMediaUploadProps) { }: FoodMediaUploadProps) {
const [activeTab, setActiveTab] = useState('image') 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
@ -82,30 +69,13 @@ export function FoodMediaUpload({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<FileUpload {imageUrl ? (
imageOnly={true} <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
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">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<img <img
src={imageUrl} src={imageUrl}
alt="食物图片预览" 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> <span className="text-sm text-gray-700"></span>
</div> </div>
@ -115,10 +85,27 @@ export function FoodMediaUpload({
size="sm" size="sm"
onClick={() => onRemove?.('image')} onClick={() => onRemove?.('image')}
className="text-red-500 hover:text-red-700 hover:bg-red-50" className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={disabled}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </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> </CardContent>
</Card> </Card>
@ -137,48 +124,26 @@ export function FoodMediaUpload({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<FileUpload {animationUrl ? (
imageOnly={false} <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
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">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{animationUrl.endsWith('.gif') ? ( {animationUrl.endsWith('.gif') ? (
<img <img
src={animationUrl} src={animationUrl}
alt="动画预览" 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) ? ( ) : animationUrl.match(/\.(mp4|avi|mov|wmv|flv)$/i) ? (
<video <video
src={animationUrl} src={animationUrl}
className="h-12 w-12 object-cover rounded border" className="h-16 w-16 object-cover rounded border"
muted muted
loop loop
autoPlay autoPlay
/> />
) : ( ) : (
<div className="h-12 w-12 bg-gray-200 rounded border flex items-center justify-center"> <div className="h-16 w-16 bg-gray-200 rounded border flex items-center justify-center">
<Play className="h-4 w-4 text-gray-500" /> <Play className="h-6 w-6 text-gray-500" />
</div> </div>
)} )}
<span className="text-sm text-gray-700"></span> <span className="text-sm text-gray-700"></span>
@ -189,10 +154,32 @@ export function FoodMediaUpload({
size="sm" size="sm"
onClick={() => onRemove?.('animation')} onClick={() => onRemove?.('animation')}
className="text-red-500 hover:text-red-700 hover:bg-red-50" className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={disabled}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </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> </CardContent>
</Card> </Card>
@ -211,30 +198,11 @@ export function FoodMediaUpload({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<FileUpload {audioUrl ? (
imageOnly={false} <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
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">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="h-12 w-12 bg-blue-100 rounded border flex items-center justify-center"> <div className="h-16 w-16 bg-blue-100 rounded border flex items-center justify-center">
<Music className="h-4 w-4 text-blue-500" /> <Music className="h-6 w-6 text-blue-500" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<span className="text-sm text-gray-700 block"></span> <span className="text-sm text-gray-700 block"></span>
@ -250,10 +218,29 @@ export function FoodMediaUpload({
size="sm" size="sm"
onClick={() => onRemove?.('audio')} onClick={() => onRemove?.('audio')}
className="text-red-500 hover:text-red-700 hover:bg-red-50" className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={disabled}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </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> </CardContent>
</Card> </Card>

View File

@ -15,9 +15,25 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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 { Switch } from "@/components/ui/switch"
import { FileUpload } from "@/components/ui/file-upload"
import type { HomeDecor } from "./home-decor-detail-dialog" 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 = { type AddHomeDecorDialogProps = {
mode?: "create" | "edit" mode?: "create" | "edit"
@ -43,6 +59,7 @@ export function AddHomeDecorDialog({
const [rarity, setRarity] = useState("") const [rarity, setRarity] = useState("")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [isLimited, setIsLimited] = useState(false) const [isLimited, setIsLimited] = useState(false)
const [imageUrl, setImageUrl] = useState<string | undefined>()
const [previewId, setPreviewId] = useState( const [previewId, setPreviewId] = useState(
"DEC" + "DEC" +
Math.floor(Math.random() * 1000) Math.floor(Math.random() * 1000)
@ -59,6 +76,7 @@ export function AddHomeDecorDialog({
setDescription(initialDecor.description) setDescription(initialDecor.description)
setIsLimited(initialDecor.type.includes("限定")) setIsLimited(initialDecor.type.includes("限定"))
setPreviewId(initialDecor.id) setPreviewId(initialDecor.id)
setImageUrl(isRealImageUrl(initialDecor.image) ? initialDecor.image : undefined)
} }
}, [mode, initialDecor]) }, [mode, initialDecor])
@ -90,6 +108,7 @@ export function AddHomeDecorDialog({
setRarity("") setRarity("")
setDescription("") setDescription("")
setIsLimited(false) setIsLimited(false)
setImageUrl(undefined)
setPreviewId( setPreviewId(
"DEC" + "DEC" +
Math.floor(Math.random() * 1000) Math.floor(Math.random() * 1000)
@ -108,27 +127,59 @@ export function AddHomeDecorDialog({
setIsSubmitting(true) setIsSubmitting(true)
try { try {
// 构建装饰对象 const actualType = isLimited ? `限定${decorType}` : decorType
const decor: HomeDecor = {
id: initialDecor?.id || previewId, if (mode === "create") {
// 调用后端创建 API
const created = await createHomeDecor({
name, name,
type: isLimited ? `限定${decorType}` : decorType,
rarity,
description, description,
releaseDate: initialDecor?.releaseDate || "", category: actualType,
status: initialDecor?.status || "未发布", rarityValue: RARITY_MAP[rarity] || rarity,
activatedCount: initialDecor?.activatedCount || 0, imageUrl,
// 不使用外部图片URL避免加载错误 })
image: undefined,
// 构建组件格式的 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) { if (onSave) {
onSave(decor) 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) handleOpenChange(false)
@ -247,11 +298,43 @@ export function AddHomeDecorDialog({
<Label className="text-right"> <Label className="text-right">
{mode === "create" && <span className="text-red-500">*</span>} {mode === "create" && <span className="text-red-500">*</span>}
</Label> </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"> {imageUrl ? (
<Upload className="h-8 w-8 text-gray-400 mb-2" /> <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<p className="text-sm text-gray-500"></p> <div className="flex items-center space-x-3">
<p className="text-xs text-gray-400 mt-1"> PNG, JPG, JPEG 5MB</p> <img
src={imageUrl}
alt="装饰图片"
className="h-16 w-16 object-cover rounded border"
/>
<span className="text-sm text-gray-700"></span>
</div> </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>
<div className="flex items-center space-x-2 pt-2"> <div className="flex items-center space-x-2 pt-2">

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@ -15,19 +15,59 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Switch } from "@/components/ui/switch" 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 [open, setOpen] = useState(false)
const [step, setStep] = useState(1)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [name, setName] = useState("")
const [outfitType, setOutfitType] = useState("") const [outfitType, setOutfitType] = useState("")
const [rarity, setRarity] = useState("") const [rarity, setRarity] = useState("")
const [printQuantity, setPrintQuantity] = useState(1000) const [description, setDescription] = useState("")
const [isLimited, setIsLimited] = useState(false) const [isLimited, setIsLimited] = useState(false)
const [imageUrl, setImageUrl] = useState<string | undefined>()
const [previewId, setPreviewId] = useState( const [previewId, setPreviewId] = useState(
"OFT" + "OFT" +
Math.floor(Math.random() * 1000) Math.floor(Math.random() * 1000)
@ -35,18 +75,42 @@ export function AddOutfitDialog() {
.padStart(3, "0"), .padStart(3, "0"),
) )
const handleSubmit = async () => { useEffect(() => {
setIsSubmitting(true) if (mode === "edit" && initialOutfit) {
// 模拟API请求 setName(initialOutfit.name)
await new Promise((resolve) => setTimeout(resolve, 1500)) setOutfitType(initialOutfit.type)
setIsSubmitting(false) setRarity(initialOutfit.rarity)
setOpen(false) setDescription(initialOutfit.description)
// 重置表单 setIsLimited(initialOutfit.type === "限定服装")
setStep(1) 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("") setOutfitType("")
setRarity("") setRarity("")
setPrintQuantity(1000) setDescription("")
setIsLimited(false) setIsLimited(false)
setImageUrl(undefined)
setPreviewId( setPreviewId(
"OFT" + "OFT" +
Math.floor(Math.random() * 1000) Math.floor(Math.random() * 1000)
@ -54,71 +118,96 @@ export function AddOutfitDialog() {
.padStart(3, "0"), .padStart(3, "0"),
) )
} }
const handleNext = () => {
setStep(step + 1)
} }
const handleBack = () => { const handleSubmit = async () => {
setStep(step - 1) if (!name || !outfitType || !rarity || !description) {
alert("请填写所有必填字段!")
return
} }
const handleClose = () => { setIsSubmitting(true)
setOpen(false) try {
setStep(1) 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 ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={handleOpenChange}>
{mode === "create" && (
<DialogTrigger asChild> <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"> <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" /> <Plus className="mr-2 h-4 w-4" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[600px]"> )}
<DialogContent className="sm:max-w-[550px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600"> <DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
{mode === "create" ? "添加新服装" : "编辑服装"}
</DialogTitle> </DialogTitle>
<DialogDescription>ID</DialogDescription> <DialogDescription>
{mode === "create" ? "填写服装信息以创建新的服装卡牌。创建后将生成唯一的卡牌ID。" : "修改服装信息。"}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4">
<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 grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name" className="text-right"> <Label htmlFor="name" className="text-right">
@ -128,6 +217,8 @@ export function AddOutfitDialog() {
id="name" id="name"
placeholder="输入服装名称" placeholder="输入服装名称"
className="border-gray-300 focus-visible:ring-pink-500" className="border-gray-300 focus-visible:ring-pink-500"
value={name}
onChange={(e) => setName(e.target.value)}
required required
/> />
</div> </div>
@ -140,10 +231,12 @@ export function AddOutfitDialog() {
<SelectValue placeholder="选择服装类型" /> <SelectValue placeholder="选择服装类型" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="regular"></SelectItem> <SelectItem value="常规服装"></SelectItem>
<SelectItem value="seasonal"></SelectItem> <SelectItem value="演出服装"></SelectItem>
<SelectItem value="festival"></SelectItem> <SelectItem value="季节限定"></SelectItem>
<SelectItem value="special"></SelectItem> <SelectItem value="节日限定"></SelectItem>
<SelectItem value="限定服装"></SelectItem>
<SelectItem value="其他"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -159,26 +252,25 @@ export function AddOutfitDialog() {
<SelectValue placeholder="选择稀有度" /> <SelectValue placeholder="选择稀有度" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="common"></SelectItem> <SelectItem value="普通"></SelectItem>
<SelectItem value="rare"></SelectItem> <SelectItem value="稀有"></SelectItem>
<SelectItem value="epic"></SelectItem> <SelectItem value="史诗"></SelectItem>
<SelectItem value="legendary"></SelectItem> <SelectItem value="传说"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="print-quantity" className="text-right"> <Label htmlFor="quantity" className="text-right">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</Label> </Label>
<Input <Input
id="print-quantity" id="quantity"
type="number" type="number"
min={1} min="1"
value={printQuantity} defaultValue="1000"
onChange={(e) => setPrintQuantity(Number.parseInt(e.target.value))}
placeholder="输入印刷数量"
className="border-gray-300 focus-visible:ring-pink-500" className="border-gray-300 focus-visible:ring-pink-500"
required required
disabled={mode === "edit"}
/> />
</div> </div>
</div> </div>
@ -191,10 +283,55 @@ export function AddOutfitDialog() {
id="description" id="description"
placeholder="输入服装描述" placeholder="输入服装描述"
className="min-h-[100px] border-gray-300 focus-visible:ring-pink-500" className="min-h-[100px] border-gray-300 focus-visible:ring-pink-500"
value={description}
onChange={(e) => setDescription(e.target.value)}
required required
/> />
</div> </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"> <div className="flex items-center space-x-2 pt-2">
<Switch id="limited" checked={isLimited} onCheckedChange={setIsLimited} /> <Switch id="limited" checked={isLimited} onCheckedChange={setIsLimited} />
<Label htmlFor="limited" className="cursor-pointer"> <Label htmlFor="limited" className="cursor-pointer">
@ -211,118 +348,23 @@ export function AddOutfitDialog() {
</div> </div>
</div> </div>
)} )}
</TabsContent>
<TabsContent value="step-2" className="space-y-4 py-4"> {mode === "create" && (
<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">
<div className="p-4 bg-gray-50 rounded-lg"> <div className="p-4 bg-gray-50 rounded-lg">
<h3 className="text-lg font-medium mb-3"></h3> <h3 className="text-sm font-medium mb-2"></h3>
<div className="grid grid-cols-2 gap-4"> <p className="text-sm text-gray-600">
<div className="space-y-1"> ID: <span className="font-medium text-pink-600">{previewId}</span>
<p className="text-sm font-medium text-gray-500">ID</p> </p>
<p className="font-medium text-pink-600">{previewId}</p> <p className="text-sm text-gray-600 mt-1">
</div> : <span className="font-medium"></span>
<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"
? "特别版"
: "-"}
</p> </p>
</div> </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>
<div className="space-y-1"> <DialogFooter>
<p className="text-sm font-medium text-gray-500"></p> <Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
<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}>
</Button> </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 <Button
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700" className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
onClick={handleSubmit} onClick={handleSubmit}
@ -331,13 +373,14 @@ export function AddOutfitDialog() {
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
... {mode === "create" ? "创建中..." : "更新中..."}
</> </>
) : ( ) : mode === "create" ? (
"创建服装" "创建服装"
) : (
"更新服装"
)} )}
</Button> </Button>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -15,9 +15,25 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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 { Switch } from "@/components/ui/switch"
import { FileUpload } from "@/components/ui/file-upload"
import type { Prop } from "./prop-detail-dialog" 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 = { type AddPropDialogProps = {
mode?: "create" | "edit" mode?: "create" | "edit"
@ -43,6 +59,7 @@ export function AddPropDialog({
const [rarity, setRarity] = useState("") const [rarity, setRarity] = useState("")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [isLimited, setIsLimited] = useState(false) const [isLimited, setIsLimited] = useState(false)
const [imageUrl, setImageUrl] = useState<string | undefined>()
const [previewId, setPreviewId] = useState( const [previewId, setPreviewId] = useState(
"PRP" + "PRP" +
Math.floor(Math.random() * 1000) Math.floor(Math.random() * 1000)
@ -59,6 +76,7 @@ export function AddPropDialog({
setDescription(initialProp.description) setDescription(initialProp.description)
setIsLimited(initialProp.type === "限定道具") setIsLimited(initialProp.type === "限定道具")
setPreviewId(initialProp.id) setPreviewId(initialProp.id)
setImageUrl(isRealImageUrl(initialProp.image) ? initialProp.image : undefined)
} }
}, [mode, initialProp]) }, [mode, initialProp])
@ -90,6 +108,7 @@ export function AddPropDialog({
setRarity("") setRarity("")
setDescription("") setDescription("")
setIsLimited(false) setIsLimited(false)
setImageUrl(undefined)
setPreviewId( setPreviewId(
"PRP" + "PRP" +
Math.floor(Math.random() * 1000) Math.floor(Math.random() * 1000)
@ -108,26 +127,59 @@ export function AddPropDialog({
setIsSubmitting(true) setIsSubmitting(true)
try { try {
// 构建道具对象 const actualType = isLimited ? "限定道具" : propType
const prop: Prop = {
id: initialProp?.id || previewId, if (mode === "create") {
// 调用后端创建 API
const created = await createProp({
name, name,
type: isLimited ? "限定道具" : propType,
rarity,
description, description,
releaseDate: initialProp?.releaseDate || "", category: actualType,
status: initialProp?.status || "未发布", rarityValue: RARITY_MAP[rarity] || rarity,
activatedCount: initialProp?.activatedCount || 0, imageUrl,
image: initialProp?.image || "/placeholder.svg?height=300&width=300", })
// 构建组件格式的 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) { if (onSave) {
onSave(prop) 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) handleOpenChange(false)
@ -243,11 +295,43 @@ export function AddPropDialog({
<Label className="text-right"> <Label className="text-right">
{mode === "create" && <span className="text-red-500">*</span>} {mode === "create" && <span className="text-red-500">*</span>}
</Label> </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"> {imageUrl ? (
<Upload className="h-8 w-8 text-gray-400 mb-2" /> <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<p className="text-sm text-gray-500"></p> <div className="flex items-center space-x-3">
<p className="text-xs text-gray-400 mt-1"> PNG, JPG, JPEG 5MB</p> <img
src={imageUrl}
alt="道具图片"
className="h-16 w-16 object-cover rounded border"
/>
<span className="text-sm text-gray-700"></span>
</div> </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>
<div className="flex items-center space-x-2 pt-2"> <div className="flex items-center space-x-2 pt-2">

View File

@ -1,10 +1,12 @@
"use client" "use client"
import { useState, useEffect } from "react"
import Link from "next/link" import Link from "next/link"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { logout } from "@/lib/api/auth" import { logout } from "@/lib/api/auth"
import { hasPermission, getUserRole, type PermissionModule } from "@/lib/permissions"
import { import {
Brain, Brain,
Music, Music,
@ -21,24 +23,88 @@ import {
Heart, Heart,
Footprints, Footprints,
Trophy, Trophy,
type LucideIcon,
} from "lucide-react" } 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() { export function Sidebar() {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const handleLogout = async () => { const handleLogout = async () => {
try { try {
// 调用退出登录API
await logout() await logout()
// 退出后重定向到登录页面
router.push("/login") router.push("/login")
} catch (error) { } catch (error) {
console.error("退出登录失败:", 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 ( return (
<div className="flex h-screen border-r bg-gradient-to-b from-white to-gray-50 shadow-md"> <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"> <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"> <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" /> <Sparkles className="h-4 w-4 text-white" />
</div> </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 className="text-xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-pink-600 to-purple-600">
</h2> </h2>
<p className="text-xs text-gray-400">{role}</p>
</div>
</div> </div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
{/* 仪表盘 - 所有角色都可见 */}
<Button <Button
variant={pathname === "/" ? "default" : "ghost"} variant={pathname === "/" ? "default" : "ghost"}
className={cn( className={cn(
@ -70,209 +140,41 @@ export function Sidebar() {
</Link> </Link>
</Button> </Button>
{/* AI 管理 */}
{visibleAiItems.length > 0 && (
<>
<div className="pt-4 pb-2"> <div className="pt-4 pb-2">
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">AI </p> <p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">AI </p>
</div> </div>
{visibleAiItems.map((item) => (
<Button <NavButton key={item.href} item={item} pathname={pathname} />
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",
)} )}
asChild
>
<Link href="/ai-model">
<Brain className="mr-2 h-4 w-4" />
</Link>
</Button>
{/* 内容管理 */}
{visibleContentItems.length > 0 && (
<>
<div className="pt-4 pb-2"> <div className="pt-4 pb-2">
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider"></p> <p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider"></p>
</div> </div>
{visibleContentItems.map((item) => (
<Button <NavButton key={item.href} item={item} pathname={pathname} />
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",
)} )}
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"> <div className="pt-4 pb-2">
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider"></p> <p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider"></p>
</div> </div>
{visibleSystemItems.map((item) => (
<Button <NavButton key={item.href} item={item} pathname={pathname} />
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",
)} )}
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>
<div className="mt-auto pt-4"> <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 { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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 { Switch } from "@/components/ui/switch"
import type { Song } from "./song-detail-dialog" import type { Song } from "./song-detail-dialog"
import { uploadSongFile, createSong, updateSong } from "@/lib/api/songs" import { uploadSongFile, createSong, updateSong } from "@/lib/api/songs"
@ -53,6 +54,7 @@ export function AddSongDialog({
.toString() .toString()
.padStart(3, "0"), .padStart(3, "0"),
) )
const [coverUrl, setCoverUrl] = useState<string>("")
const [audioFile, setAudioFile] = useState<File | null>(null) const [audioFile, setAudioFile] = useState<File | null>(null)
const [audioUrl, setAudioUrl] = useState<string>("") const [audioUrl, setAudioUrl] = useState<string>("")
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
@ -85,6 +87,11 @@ export function AddSongDialog({
setDescription(initialSong.description) setDescription(initialSong.description)
} }
// 初始化封面URL
if (initialSong.image) {
setCoverUrl(initialSong.image)
}
// 初始化音频URL // 初始化音频URL
if (initialSong.audioUrl) { if (initialSong.audioUrl) {
setAudioUrl(initialSong.audioUrl) setAudioUrl(initialSong.audioUrl)
@ -187,7 +194,7 @@ export function AddSongDialog({
if (mode === "create") { if (mode === "create") {
// 创建模式 // 创建模式
const payload = { const payload: any = {
name, name,
category: "song", category: "song",
card_type: "regular", card_type: "regular",
@ -197,15 +204,21 @@ export function AddSongDialog({
description: description || undefined, description: description || undefined,
song_attributes: songAttributes song_attributes: songAttributes
} }
if (coverUrl) {
payload.image_url = coverUrl
}
const created = await createSong(payload) const created = await createSong(payload)
if (onSave) onSave(created) if (onSave) onSave(created)
} else if (mode === "edit" && initialSong) { } else if (mode === "edit" && initialSong) {
// 编辑模式 // 编辑模式
const payload = { const payload: any = {
name, name,
category: "song", category: "song",
song_attributes: songAttributes song_attributes: songAttributes
} }
if (coverUrl) {
payload.image_url = coverUrl
}
const updated = await updateSong(initialSong.id, payload) const updated = await updateSong(initialSong.id, payload)
if (onSave) onSave(updated) if (onSave) onSave(updated)
} }
@ -299,10 +312,43 @@ export function AddSongDialog({
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"></Label> <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]"> {coverUrl && !coverUrl.includes("placeholder") ? (
<Upload className="h-5 w-5 text-gray-400 mb-1" /> <div className="flex items-center justify-between p-2 bg-gray-50 rounded-md border min-h-[60px]">
<span className="text-xs text-gray-500"></span> <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> </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>
</div> </div>
{/* 可折叠描述和预览信息 */} {/* 可折叠描述和预览信息 */}

View File

@ -1,266 +1,117 @@
import type { Achievement } from "./types" import type { Achievement } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client" import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟成就数据 // 将后端成就数据映射到前端 Achievement 类型
const mockAchievements: Achievement[] = [ function mapBackendAchievement(a: any): Achievement {
{ // 后端 achievement_type → 前端 category 映射
id: "1", const categoryMap: Record<string, Achievement["category"]> = {
name: "初次见面", login: "互动",
description: "第一次与洛天依对话", activity: "互动",
icon: "message-circle", social: "互动",
category: "互动", usage: "收集",
requirement: "与洛天依进行第一次对话", 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: "经验值", rewardType: "经验值",
rewardAmount: 100, rewardAmount: a.points || 0,
rewardIcon: "zap", isHidden: a.is_hidden || false,
isHidden: false, unlockRate: undefined,
unlockRate: 98.5, createdAt: a.created_at ? a.created_at.split("T")[0] : "",
createdAt: "2023-01-01", updatedAt: a.updated_at ? a.updated_at.split("T")[0] : "",
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",
},
]
// 获取成就列表 // 获取成就列表
export const getAchievements = async (params?: PaginationParams): Promise<PaginatedResponse<Achievement>> => { 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 page = params?.page || 1
const pageSize = params?.pageSize || 10 const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
const paginatedAchievements = filteredAchievements.slice(startIndex, startIndex + pageSize)
return mockResponse({ const response = await apiClient.get(
items: paginatedAchievements, `/achievement/achievements/?page=${page}&page_size=${pageSize}&show_hidden=true${searchParam}`
total: filteredAchievements.length, )
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, page,
pageSize, pageSize,
totalPages: Math.ceil(filteredAchievements.length / pageSize), totalPages: Math.ceil(total / pageSize),
}) }
} }
// 获取单个成就 // 获取单个成就
export const getAchievement = async (id: string): Promise<Achievement> => { export const getAchievement = async (id: string): Promise<Achievement> => {
const achievement = mockAchievements.find((achievement) => achievement.id === id) const response = await apiClient.get(`/achievement/achievements/${id}/`)
const data = response.data?.data || response.data
if (!achievement) { return mapBackendAchievement(data)
return mockResponse({} as Achievement, "成就不存在")
} }
return mockResponse(achievement) // 创建成就(需要管理员权限)
}
// 创建成就
export const createAchievement = async (achievementData: Partial<Achievement>): Promise<Achievement> => { export const createAchievement = async (achievementData: Partial<Achievement>): Promise<Achievement> => {
const newAchievement: Achievement = { const typeReverseMap: Record<string, string> = {
id: String(mockAchievements.length + 1), : "activity",
name: achievementData.name || "", : "activity",
description: achievementData.description || "", : "usage",
icon: achievementData.icon || "award", : "activity",
category: achievementData.category || "特殊", : "special",
requirement: achievementData.requirement || "", : "hidden",
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],
} }
mockAchievements.push(newAchievement) const payload = {
name: achievementData.name,
return mockResponse(newAchievement) 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> => { export const updateAchievement = async (id: string, achievementData: Partial<Achievement>): Promise<Achievement> => {
const achievementIndex = mockAchievements.findIndex((achievement) => achievement.id === id) const typeReverseMap: Record<string, string> = {
: "activity",
if (achievementIndex === -1) { : "activity",
return mockResponse({} as Achievement, "成就不存在") : "usage",
: "activity",
: "special",
: "hidden",
} }
const updatedAchievement = { const payload: any = {}
...mockAchievements[achievementIndex], if (achievementData.name !== undefined) payload.name = achievementData.name
...achievementData, if (achievementData.description !== undefined) payload.description = achievementData.description
updatedAt: new Date().toISOString().split("T")[0], 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> => { export const deleteAchievement = async (id: string): Promise<boolean> => {
const achievementIndex = mockAchievements.findIndex((achievement) => achievement.id === id) await apiClient.delete(`/achievement/achievements/${id}/`)
return true
if (achievementIndex === -1) {
return mockResponse(false, "成就不存在")
}
mockAchievements.splice(achievementIndex, 1)
return mockResponse(true)
} }

View File

@ -1,306 +1,138 @@
import type { AffinityRule, AffinityLevel } from "./types" import type { AffinityRule, AffinityLevel } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client" import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟好感度规则数据 // 将后端 AffinityRule 数据映射到前端类型
const mockAffinityRules: AffinityRule[] = [ function mapBackendRule(r: any): AffinityRule {
{ return {
id: "AFR001", id: String(r.id),
name: "每日登录", action: r.name,
description: "用户每日登录系统可获得好感度", points: r.points || 0,
points: 10, description: r.description || "",
type: "日常活动", dailyLimit: r.daily_limit ?? undefined,
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: "未启用",
},
]
// 模拟好感度等级数据 // 将后端 AffinityLevel 数据映射到前端类型
const mockAffinityLevels: AffinityLevel[] = [ function mapBackendLevel(l: any): AffinityLevel {
{ return {
id: "AFL001", id: String(l.id),
level: 1, level: l.level,
name: "初识", name: l.name,
pointsRequired: 0, description: l.description || "",
rewards: ["基础表情包", "基础头像框"], requiredPoints: l.required_points,
description: "刚刚认识洛天依,开始了解她的世界。", rewards: Array.isArray(l.rewards) ? l.rewards : [],
}, }
{ }
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: "与洛天依建立了最亲密的关系,成为了无可替代的挚友。",
},
]
// 获取好感度规则列表
export const getAffinityRules = async (params?: PaginationParams): Promise<PaginatedResponse<AffinityRule>> => { 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 page = params?.page || 1
const pageSize = params?.pageSize || 10 const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize
const paginatedRules = filteredRules.slice(startIndex, startIndex + pageSize)
return mockResponse({ const response = await apiClient.get(`/user/affinity-rules/?page=${page}&page_size=${pageSize}`)
items: paginatedRules, const data = response.data?.data || response.data
total: filteredRules.length, const results: any[] = data.results || (Array.isArray(data) ? data : [])
const total = data.count || results.length
return {
items: results.map(mapBackendRule),
total,
page, page,
pageSize, pageSize,
totalPages: Math.ceil(filteredRules.length / pageSize), totalPages: Math.ceil(total / pageSize),
}) }
} }
// 获取单个好感度规则
export const getAffinityRule = async (id: string): Promise<AffinityRule> => { export const getAffinityRule = async (id: string): Promise<AffinityRule> => {
const rule = mockAffinityRules.find((rule) => rule.id === id) const response = await apiClient.get(`/user/affinity-rules/${id}/`)
const data = response.data?.data || response.data
if (!rule) { return mapBackendRule(data)
return mockResponse({} as AffinityRule, "好感度规则不存在")
} }
return mockResponse(rule)
}
// 创建好感度规则
export const createAffinityRule = async (ruleData: Partial<AffinityRule>): Promise<AffinityRule> => { export const createAffinityRule = async (ruleData: Partial<AffinityRule>): Promise<AffinityRule> => {
// 生成新的好感度规则ID const payload = {
const ruleId = "AFR" + String(mockAffinityRules.length + 1).padStart(3, "0") name: ruleData.action,
const newRule: AffinityRule = {
id: ruleId,
name: ruleData.name || "",
description: ruleData.description || "", description: ruleData.description || "",
points: ruleData.points || 0, points: ruleData.points || 0,
type: ruleData.type || "", daily_limit: ruleData.dailyLimit ?? null,
status: ruleData.status || "未启用", 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> => { 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) { const response = await apiClient.patch(`/user/affinity-rules/${id}/`, payload)
return mockResponse({} as AffinityRule, "好感度规则不存在") 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> => { export const deleteAffinityRule = async (id: string): Promise<boolean> => {
const ruleIndex = mockAffinityRules.findIndex((rule) => rule.id === id) await apiClient.delete(`/user/affinity-rules/${id}/`)
return true
if (ruleIndex === -1) {
return mockResponse(false, "好感度规则不存在")
} }
mockAffinityRules.splice(ruleIndex, 1) // ── 好感度等级 ────────────────────────────────────────────
return mockResponse(true)
}
// 获取好感度等级列表
export const getAffinityLevels = async (params?: PaginationParams): Promise<PaginatedResponse<AffinityLevel>> => { 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 page = params?.page || 1
const pageSize = params?.pageSize || 10 const pageSize = params?.pageSize || 50 // 等级数量通常较少,一次全取
const startIndex = (page - 1) * pageSize
const paginatedLevels = filteredLevels.slice(startIndex, startIndex + pageSize)
return mockResponse({ const response = await apiClient.get(`/user/affinity-levels/?page=${page}&page_size=${pageSize}`)
items: paginatedLevels, const data = response.data?.data || response.data
total: filteredLevels.length, const results: any[] = data.results || (Array.isArray(data) ? data : [])
const total = data.count || results.length
return {
items: results.map(mapBackendLevel),
total,
page, page,
pageSize, pageSize,
totalPages: Math.ceil(filteredLevels.length / pageSize), totalPages: Math.ceil(total / pageSize),
}) }
} }
// 获取单个好感度等级
export const getAffinityLevel = async (id: string): Promise<AffinityLevel> => { export const getAffinityLevel = async (id: string): Promise<AffinityLevel> => {
const level = mockAffinityLevels.find((level) => level.id === id) const response = await apiClient.get(`/user/affinity-levels/${id}/`)
const data = response.data?.data || response.data
if (!level) { return mapBackendLevel(data)
return mockResponse({} as AffinityLevel, "好感度等级不存在")
} }
return mockResponse(level)
}
// 创建好感度等级
export const createAffinityLevel = async (levelData: Partial<AffinityLevel>): Promise<AffinityLevel> => { export const createAffinityLevel = async (levelData: Partial<AffinityLevel>): Promise<AffinityLevel> => {
// 生成新的好感度等级ID const payload = {
const levelId = "AFL" + String(mockAffinityLevels.length + 1).padStart(3, "0") level: levelData.level,
name: levelData.name,
const newLevel: AffinityLevel = {
id: levelId,
level: levelData.level || mockAffinityLevels.length + 1,
name: levelData.name || "",
pointsRequired: levelData.pointsRequired || 0,
rewards: levelData.rewards || [],
description: levelData.description || "", 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> => { 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) { const response = await apiClient.patch(`/user/affinity-levels/${id}/`, payload)
return mockResponse({} as AffinityLevel, "好感度等级不存在") 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> => { export const deleteAffinityLevel = async (id: string): Promise<boolean> => {
const levelIndex = mockAffinityLevels.findIndex((level) => level.id === id) await apiClient.delete(`/user/affinity-levels/${id}/`)
return true
if (levelIndex === -1) {
return mockResponse(false, "好感度等级不存在")
}
mockAffinityLevels.splice(levelIndex, 1)
return mockResponse(true)
} }

View File

@ -1,185 +1,84 @@
import type { AiModel } from "./types" import type { AiModel } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client" import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟AI模型数据 // 将后端 Bot 数据映射到前端 AiModel 类型
const mockAiModels: AiModel[] = [ // 后端 Bot 只有 id, name, description其他字段用默认值
{ function mapBackendBot(b: any): AiModel {
id: "AI001", return {
name: "洛天依语音合成模型V1", id: String(b.id),
name: b.name,
version: "1.0.0", version: "1.0.0",
type: "语音合成", description: b.description || "",
status: "已发布", status: "活跃",
createdAt: "2023-01-15", parameters: undefined,
updatedAt: "2023-01-15", accuracy: undefined,
description: "洛天依的基础语音合成模型,支持基本的歌声合成功能。", trainingData: undefined,
}, createdAt: undefined,
{ updatedAt: undefined,
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: "洛天依的多模态模型,支持图像识别和生成,以及与语音、文本的交互。",
},
]
// 获取AI模型列表 // 获取AI模型(Bot)列表
export const getAiModels = async (params?: PaginationParams): Promise<PaginatedResponse<AiModel>> => { 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 page = params?.page || 1
const pageSize = params?.pageSize || 10 const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize)
return mockResponse({ const response = await apiClient.get(
items: paginatedModels, `/ai/bots/?page=${page}&page_size=${pageSize}${searchParam}`
total: filteredModels.length, )
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, page,
pageSize, pageSize,
totalPages: Math.ceil(filteredModels.length / pageSize), totalPages: Math.ceil(total / pageSize),
}) }
} }
// 获取单个AI模型 // 获取单个AI模型
export const getAiModel = async (id: string): Promise<AiModel> => { export const getAiModel = async (id: string): Promise<AiModel> => {
const model = mockAiModels.find((model) => model.id === id) const response = await apiClient.get(`/ai/bots/${id}/`)
const data = response.data?.data || response.data
if (!model) { return mapBackendBot(data)
return mockResponse({} as AiModel, "AI模型不存在")
} }
return mockResponse(model) // 创建AI模型需要管理员权限
}
// 创建AI模型
export const createAiModel = async (modelData: Partial<AiModel>): Promise<AiModel> => { export const createAiModel = async (modelData: Partial<AiModel>): Promise<AiModel> => {
// 生成新的AI模型ID const payload = {
const modelId = "AI" + String(mockAiModels.length + 1).padStart(3, "0") name: modelData.name,
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,
description: modelData.description || "", description: modelData.description || "",
} }
mockAiModels.push(newModel) const response = await apiClient.post(`/ai/bots/`, payload)
const data = response.data?.data || response.data
return mockResponse(newModel) return mapBackendBot(data)
} }
// 更新AI模型 // 更新AI模型需要管理员权限
export const updateAiModel = async (id: string, modelData: Partial<AiModel>): Promise<AiModel> => { 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) { const response = await apiClient.patch(`/ai/bots/${id}/`, payload)
return mockResponse({} as AiModel, "AI模型不存在") const data = response.data?.data || response.data
return mapBackendBot(data)
} }
const updatedModel = { // 删除AI模型需要管理员权限
...mockAiModels[modelIndex],
...modelData,
updatedAt: new Date().toISOString().split("T")[0],
}
mockAiModels[modelIndex] = updatedModel
return mockResponse(updatedModel)
}
// 删除AI模型
export const deleteAiModel = async (id: string): Promise<boolean> => { export const deleteAiModel = async (id: string): Promise<boolean> => {
const modelIndex = mockAiModels.findIndex((model) => model.id === id) await apiClient.delete(`/ai/bots/${id}/`)
return true
if (modelIndex === -1) {
return mockResponse(false, "AI模型不存在")
} }
// 已发布的AI模型不能删除 // 发布AI模型Bot没有发布概念这里直接返回当前数据
if (mockAiModels[modelIndex].status === "已发布") {
return mockResponse(false, "已发布的AI模型不能删除")
}
mockAiModels.splice(modelIndex, 1)
return mockResponse(true)
}
// 发布AI模型
export const publishAiModel = async (id: string): Promise<AiModel> => { export const publishAiModel = async (id: string): Promise<AiModel> => {
const modelIndex = mockAiModels.findIndex((model) => model.id === id) return getAiModel(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])
} }

View File

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

View File

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

View File

@ -1,193 +1,46 @@
import type { Dance } from "./types" import type { Dance } from "./types"
import { apiClient } from "./client"
import { handleApiError } from "./error-handler" import { handleApiError } from "./error-handler"
// 模拟舞蹈数据 // 将后端 CardTemplate(dance) 数据映射到前端 Dance 类型
const mockDances: Dance[] = [ function mapBackendDance(item: any): Dance {
{ const attrs = item.attributes || {}
id: "1", return {
name: "千本樱", id: String(item.id),
choreographer: "洛天依工作室", name: item.name,
duration: "3:45", choreographer: attrs.choreographer || "",
difficulty: "中等", duration: attrs.duration || "",
videoUrl: "/placeholder.svg?height=300&width=400", difficulty: attrs.difficulty || "",
coverUrl: "/placeholder.svg?height=300&width=400", videoUrl: attrs.tutorial_video || item.model_url || "",
description: "基于《千本樱》歌曲的经典舞蹈编排,动作流畅优美,适合中等水平的舞者。", coverUrl: item.image_url || "/placeholder.svg?height=300&width=400",
motionFile: "senbonzakura_motion.fbx", description: item.description || "",
category: "日式", motionFile: item.model_url || "",
tags: ["经典", "流行", "日式"], category: attrs.style || "",
createdAt: "2023-01-15T08:30:00Z", tags: [],
updatedAt: "2023-02-20T14:15:00Z", createdAt: item.created_at || "",
}, updatedAt: item.updated_at || "",
{ }
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",
},
]
// 获取舞蹈列表 // 获取舞蹈列表
export async function getDances(page = 1, limit = 10, search = "") { export async function getDances(page = 1, limit = 10, search = "") {
try { try {
// 模拟API请求延迟 const searchParam = search ? `&search=${encodeURIComponent(search)}` : ""
await new Promise((resolve) => setTimeout(resolve, 500)) const response = await apiClient.get(
`/card/category/dance/?page=${page}&page_size=${limit}${searchParam}`
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 data = response.data?.data || response.data
const totalItems = filteredDances.length const results: any[] = data.results || data
const totalPages = Math.ceil(totalItems / limit) const total = data.count || results.length
const startIndex = (page - 1) * limit
const paginatedDances = filteredDances.slice(startIndex, startIndex + limit)
return { return {
data: paginatedDances, data: results.map(mapBackendDance),
pagination: { pagination: {
page, page,
limit, limit,
totalItems, totalItems: total,
totalPages, totalPages: Math.ceil(total / limit),
}, },
} }
} catch (error) { } catch (error) {
@ -198,90 +51,70 @@ export async function getDances(page = 1, limit = 10, search = "") {
// 获取单个舞蹈详情 // 获取单个舞蹈详情
export async function getDance(id: string) { export async function getDance(id: string) {
try { try {
// 模拟API请求延迟 const response = await apiClient.get(`/card/templates/${id}/`)
await new Promise((resolve) => setTimeout(resolve, 300)) const data = response.data?.data || response.data
return { data: mapBackendDance(data) }
const dance = mockDances.find((dance) => dance.id === id)
if (!dance) {
throw new Error("舞蹈不存在")
}
return { data: dance }
} catch (error) { } catch (error) {
return handleApiError(error) return handleApiError(error)
} }
} }
// 创建新舞蹈 // 创建新舞蹈(需要管理员权限)
export async function createDance(danceData: Omit<Dance, "id" | "createdAt" | "updatedAt">) { export async function createDance(danceData: Omit<Dance, "id" | "createdAt" | "updatedAt">) {
try { try {
// 模拟API请求延迟 const payload: any = {
await new Promise((resolve) => setTimeout(resolve, 700)) name: danceData.name,
category: "dance",
const newDance: Dance = { description: danceData.description || "",
id: `${mockDances.length + 1}`, dance_attributes: {
...danceData, choreographer: danceData.choreographer || "",
createdAt: new Date().toISOString(), duration: danceData.duration || "",
updatedAt: new Date().toISOString(), difficulty: danceData.difficulty || "",
tutorial_video: danceData.videoUrl || "",
style: danceData.category || "",
},
}
if (danceData.coverUrl && !danceData.coverUrl.includes("placeholder")) {
payload.image_url = danceData.coverUrl
} }
// 在实际应用中这里会调用API将数据保存到数据库 const response = await apiClient.post(`/card/templates/`, payload)
// 这里我们只是模拟添加到本地数组 const data = response.data?.data || response.data
mockDances.push(newDance) return { data: mapBackendDance(data) }
return { data: newDance }
} catch (error) { } catch (error) {
return handleApiError(error) return handleApiError(error)
} }
} }
// 更新舞蹈 // 更新舞蹈(需要管理员权限)
export async function updateDance(id: string, danceData: Partial<Dance>) { export async function updateDance(id: string, danceData: Partial<Dance>) {
try { try {
// 模拟API请求延迟 const payload: any = {}
await new Promise((resolve) => setTimeout(resolve, 600)) 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) { const response = await apiClient.patch(`/card/templates/${id}/`, payload)
throw new Error("舞蹈不存在") const data = response.data?.data || response.data
} return { data: mapBackendDance(data) }
// 更新舞蹈数据
const updatedDance = {
...mockDances[index],
...danceData,
updatedAt: new Date().toISOString(),
}
// 在实际应用中这里会调用API将更新保存到数据库
// 这里我们只是模拟更新本地数组
mockDances[index] = updatedDance
return { data: updatedDance }
} catch (error) { } catch (error) {
return handleApiError(error) return handleApiError(error)
} }
} }
// 删除舞蹈 // 删除舞蹈(需要管理员权限)
export async function deleteDance(id: string) { export async function deleteDance(id: string) {
try { try {
// 模拟API请求延迟 await apiClient.delete(`/card/templates/${id}/`)
await new Promise((resolve) => setTimeout(resolve, 500)) return { data: { id } }
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 }
} catch (error) { } catch (error) {
return handleApiError(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 * @param id ID

View File

@ -1,188 +1,103 @@
import type { HomeDecor } from "./types" import type { HomeDecor } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client" import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟家居装饰数据 function mapBackendHomeDecor(item: any): HomeDecor {
const mockHomeDecors: HomeDecor[] = [ const attrs = item.attributes || {}
{ return {
id: "DEC001", id: String(item.id),
name: "星空投影灯", name: item.name,
type: "灯饰", description: item.description || "",
rarity: "稀有", imageUrl: item.image_url || "",
description: "可以在房间内投影出美丽的星空,营造浪漫氛围。", rarity: item.rarity_display || item.rarity || "",
releaseDate: "2023-10-20", category: attrs.decoration_type || attrs.style || "",
status: "已发布", status: item.status_display || item.status || "",
activatedCount: 1342, publishedAt: item.published_at || "",
image: "/placeholder.svg?height=300&width=300", batchesCount: item.batches_count || 0,
}, activeCardsCount: item.active_cards_count || 0,
{ tags: attrs.placement ? [attrs.placement] : [],
id: "DEC002", createdAt: item.created_at || "",
name: "音乐主题壁纸", updatedAt: item.updated_at || "",
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",
},
]
// 获取家居装饰列表
export const getHomeDecors = async (params?: PaginationParams): Promise<PaginatedResponse<HomeDecor>> => { 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 page = params?.page || 1
const pageSize = params?.pageSize || 10 const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
const paginatedDecors = filteredDecors.slice(startIndex, startIndex + pageSize)
return mockResponse({ const response = await apiClient.get(
items: paginatedDecors, `/card/category/decoration/?page=${page}&page_size=${pageSize}${searchParam}`
total: filteredDecors.length, )
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, page,
pageSize, pageSize,
totalPages: Math.ceil(filteredDecors.length / pageSize), totalPages: Math.ceil(total / pageSize),
}) }
} }
// 获取单个家居装饰
export const getHomeDecor = async (id: string): Promise<HomeDecor> => { export const getHomeDecor = async (id: string): Promise<HomeDecor> => {
const decor = mockHomeDecors.find((decor) => decor.id === id) const response = await apiClient.get(`/card/templates/${id}/`)
const data = response.data?.data || response.data
if (!decor) { return mapBackendHomeDecor(data)
return mockResponse({} as HomeDecor, "家居装饰不存在")
} }
return mockResponse(decor) export const createHomeDecor = async (decorData: Partial<HomeDecor> & { rarityValue?: string }): Promise<HomeDecor> => {
} const payload: any = {
name: decorData.name,
// 创建家居装饰 category: "decoration",
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 || "",
description: decorData.description || "", description: decorData.description || "",
releaseDate: decorData.releaseDate || "", }
status: decorData.status || "未发布", if (decorData.rarityValue) {
activatedCount: 0, payload.rarity = decorData.rarityValue
image: decorData.image || "/placeholder.svg?height=300&width=300", }
if (decorData.imageUrl) {
payload.image_url = decorData.imageUrl
}
if (decorData.category) {
payload.decoration_attributes = {
decoration_type: decorData.category,
}
} }
mockHomeDecors.push(newDecor) const response = await apiClient.post(`/card/templates/`, payload)
const data = response.data?.data || response.data
return mockResponse(newDecor) return mapBackendHomeDecor(data)
} }
// 更新家居装饰 export const updateHomeDecor = async (id: string, decorData: Partial<HomeDecor> & { rarityValue?: string }): Promise<HomeDecor> => {
export const updateHomeDecor = async (id: string, decorData: Partial<HomeDecor>): Promise<HomeDecor> => { const payload: any = {}
const decorIndex = mockHomeDecors.findIndex((decor) => decor.id === id) 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) { const response = await apiClient.patch(`/card/templates/${id}/`, payload)
return mockResponse({} as HomeDecor, "家居装饰不存在") 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> => { export const deleteHomeDecor = async (id: string): Promise<boolean> => {
const decorIndex = mockHomeDecors.findIndex((decor) => decor.id === id) await apiClient.delete(`/card/templates/${id}/`)
return true
if (decorIndex === -1) {
return mockResponse(false, "家居装饰不存在")
} }
// 已发布的家居装饰不能删除
if (mockHomeDecors[decorIndex].status === "已发布") {
return mockResponse(false, "已发布的家居装饰不能删除")
}
mockHomeDecors.splice(decorIndex, 1)
return mockResponse(true)
}
// 发布家居装饰
export const publishHomeDecor = async (id: string): Promise<HomeDecor> => { export const publishHomeDecor = async (id: string): Promise<HomeDecor> => {
const decorIndex = mockHomeDecors.findIndex((decor) => decor.id === id) const response = await apiClient.post(`/card/templates/${id}/publish/`)
const data = response.data?.template || response.data
if (decorIndex === -1) { return mapBackendHomeDecor(data)
return mockResponse({} as HomeDecor, "家居装饰不存在")
} }
mockHomeDecors[decorIndex].status = "已发布" export const archiveHomeDecor = async (id: string): Promise<HomeDecor> => {
mockHomeDecors[decorIndex].releaseDate = new Date().toISOString().split("T")[0] const response = await apiClient.post(`/card/templates/${id}/archive/`)
const data = response.data?.template || response.data
return mockResponse(mockHomeDecors[decorIndex]) return mapBackendHomeDecor(data)
} }

View File

@ -1,189 +1,103 @@
import type { Outfit } from "./types" import type { Outfit } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client" import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟服装数据 function mapBackendOutfit(item: any): Outfit {
const mockOutfits: Outfit[] = [ const attrs = item.attributes || {}
{ return {
id: "OUT001", id: String(item.id),
name: "星空礼裙", name: item.name,
designer: "星辰设计工作室", description: item.description || "",
description: "以星空为灵感设计的华丽礼裙,适合正式场合穿着。", imageUrl: item.image_url || "",
releaseDate: "2023-08-15", rarity: item.rarity_display || item.rarity || "",
status: "已发布", category: attrs.style || item.card_type_display || "",
type: "礼服", status: item.status_display || item.status || "",
rarity: "传说", publishedAt: item.published_at || "",
image: "/placeholder.svg?height=300&width=300", batchesCount: item.batches_count || 0,
}, activeCardsCount: item.active_cards_count || 0,
{ tags: attrs.season ? [attrs.season] : [],
id: "OUT002", createdAt: item.created_at || "",
name: "音符连衣裙", updatedAt: item.updated_at || "",
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",
},
]
// 获取服装列表
export const getOutfits = async (params?: PaginationParams): Promise<PaginatedResponse<Outfit>> => { 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 page = params?.page || 1
const pageSize = params?.pageSize || 10 const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
const paginatedOutfits = filteredOutfits.slice(startIndex, startIndex + pageSize)
return mockResponse({ const response = await apiClient.get(
items: paginatedOutfits, `/card/category/clothing/?page=${page}&page_size=${pageSize}${searchParam}`
total: filteredOutfits.length, )
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, page,
pageSize, pageSize,
totalPages: Math.ceil(filteredOutfits.length / pageSize), totalPages: Math.ceil(total / pageSize),
}) }
} }
// 获取单个服装
export const getOutfit = async (id: string): Promise<Outfit> => { export const getOutfit = async (id: string): Promise<Outfit> => {
const outfit = mockOutfits.find((outfit) => outfit.id === id) const response = await apiClient.get(`/card/templates/${id}/`)
const data = response.data?.data || response.data
if (!outfit) { return mapBackendOutfit(data)
return mockResponse({} as Outfit, "服装不存在")
} }
return mockResponse(outfit) export const createOutfit = async (outfitData: Partial<Outfit> & { rarityValue?: string }): Promise<Outfit> => {
} const payload: any = {
name: outfitData.name,
// 创建服装 category: "clothing",
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 || "",
description: outfitData.description || "", description: outfitData.description || "",
releaseDate: outfitData.releaseDate || "", }
status: outfitData.status || "未发布", if (outfitData.rarityValue) {
type: outfitData.type || "", payload.rarity = outfitData.rarityValue
rarity: outfitData.rarity || "", }
image: outfitData.image || "/placeholder.svg?height=300&width=300", if (outfitData.imageUrl) {
payload.image_url = outfitData.imageUrl
}
if (outfitData.category) {
payload.clothing_attributes = {
style: outfitData.category,
}
} }
mockOutfits.push(newOutfit) const response = await apiClient.post(`/card/templates/`, payload)
const data = response.data?.data || response.data
return mockResponse(newOutfit) return mapBackendOutfit(data)
} }
// 更新服装 export const updateOutfit = async (id: string, outfitData: Partial<Outfit> & { rarityValue?: string }): Promise<Outfit> => {
export const updateOutfit = async (id: string, outfitData: Partial<Outfit>): Promise<Outfit> => { const payload: any = {}
const outfitIndex = mockOutfits.findIndex((outfit) => outfit.id === id) 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) { const response = await apiClient.patch(`/card/templates/${id}/`, payload)
return mockResponse({} as Outfit, "服装不存在") 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> => { export const deleteOutfit = async (id: string): Promise<boolean> => {
const outfitIndex = mockOutfits.findIndex((outfit) => outfit.id === id) await apiClient.delete(`/card/templates/${id}/`)
return true
if (outfitIndex === -1) {
return mockResponse(false, "服装不存在")
} }
// 已发布的服装不能删除
if (mockOutfits[outfitIndex].status === "已发布") {
return mockResponse(false, "已发布的服装不能删除")
}
mockOutfits.splice(outfitIndex, 1)
return mockResponse(true)
}
// 发布服装
export const publishOutfit = async (id: string): Promise<Outfit> => { export const publishOutfit = async (id: string): Promise<Outfit> => {
const outfitIndex = mockOutfits.findIndex((outfit) => outfit.id === id) const response = await apiClient.post(`/card/templates/${id}/publish/`)
const data = response.data?.template || response.data
if (outfitIndex === -1) { return mapBackendOutfit(data)
return mockResponse({} as Outfit, "服装不存在")
} }
mockOutfits[outfitIndex].status = "已发布" export const archiveOutfit = async (id: string): Promise<Outfit> => {
mockOutfits[outfitIndex].releaseDate = new Date().toISOString().split("T")[0] const response = await apiClient.post(`/card/templates/${id}/archive/`)
const data = response.data?.template || response.data
return mockResponse(mockOutfits[outfitIndex]) return mapBackendOutfit(data)
} }

View File

@ -1,188 +1,104 @@
import type { Prop } from "./types" import type { Prop } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client" import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟道具数据 function mapBackendProp(item: any): Prop {
const mockProps: Prop[] = [ const attrs = item.attributes || {}
{ return {
id: "PRP001", id: String(item.id),
name: "魔法麦克风", name: item.name,
type: "演出道具", description: item.description || "",
rarity: "稀有", imageUrl: item.image_url || "/placeholder.svg?height=300&width=400",
description: "洛天依的经典原创道具,可以增强歌声的魔力,让听众更加沉浸在音乐中。", rarity: item.rarity_display || item.rarity || "",
releaseDate: "2023-11-15", category: attrs.prop_type || "",
status: "已发布", status: item.status_display || item.status || "",
activatedCount: 1245, publishedAt: item.published_at || "",
image: "/placeholder.svg?height=300&width=300", batchesCount: item.batches_count || 0,
}, activeCardsCount: item.active_cards_count || 0,
{ tags: attrs.material ? [attrs.material] : [],
id: "PRP002", createdAt: item.created_at || "",
name: "星光魔杖", updatedAt: item.updated_at || "",
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",
},
]
// 获取道具列表
export const getProps = async (params?: PaginationParams): Promise<PaginatedResponse<Prop>> => { 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 page = params?.page || 1
const pageSize = params?.pageSize || 10 const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
const paginatedProps = filteredProps.slice(startIndex, startIndex + pageSize)
return mockResponse({ const response = await apiClient.get(
items: paginatedProps, `/card/category/prop/?page=${page}&page_size=${pageSize}${searchParam}`
total: filteredProps.length, )
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, page,
pageSize, pageSize,
totalPages: Math.ceil(filteredProps.length / pageSize), totalPages: Math.ceil(total / pageSize),
}) }
} }
// 获取单个道具
export const getProp = async (id: string): Promise<Prop> => { export const getProp = async (id: string): Promise<Prop> => {
const prop = mockProps.find((prop) => prop.id === id) const response = await apiClient.get(`/card/templates/${id}/`)
const data = response.data?.data || response.data
if (!prop) { return mapBackendProp(data)
return mockResponse({} as Prop, "道具不存在")
} }
return mockResponse(prop) export const createProp = async (propData: Partial<Prop> & { rarityValue?: string }): Promise<Prop> => {
} const payload: any = {
name: propData.name,
// 创建道具 category: "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 || "",
description: propData.description || "", description: propData.description || "",
releaseDate: propData.releaseDate || "", }
status: propData.status || "未发布", if (propData.rarityValue) {
activatedCount: 0, payload.rarity = propData.rarityValue
image: propData.image || "/placeholder.svg?height=300&width=300", }
if (propData.imageUrl) {
payload.image_url = propData.imageUrl
}
// 只在有实际属性值时才传 prop_attributes
if (propData.category) {
payload.prop_attributes = {
prop_type: propData.category,
}
} }
mockProps.push(newProp) const response = await apiClient.post(`/card/templates/`, payload)
const data = response.data?.data || response.data
return mockResponse(newProp) return mapBackendProp(data)
} }
// 更新道具 export const updateProp = async (id: string, propData: Partial<Prop> & { rarityValue?: string }): Promise<Prop> => {
export const updateProp = async (id: string, propData: Partial<Prop>): Promise<Prop> => { const payload: any = {}
const propIndex = mockProps.findIndex((prop) => prop.id === id) 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) { const response = await apiClient.patch(`/card/templates/${id}/`, payload)
return mockResponse({} as Prop, "道具不存在") 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> => { export const deleteProp = async (id: string): Promise<boolean> => {
const propIndex = mockProps.findIndex((prop) => prop.id === id) await apiClient.delete(`/card/templates/${id}/`)
return true
if (propIndex === -1) {
return mockResponse(false, "道具不存在")
} }
// 已发布的道具不能删除
if (mockProps[propIndex].status === "已发布") {
return mockResponse(false, "已发布的道具不能删除")
}
mockProps.splice(propIndex, 1)
return mockResponse(true)
}
// 发布道具
export const publishProp = async (id: string): Promise<Prop> => { export const publishProp = async (id: string): Promise<Prop> => {
const propIndex = mockProps.findIndex((prop) => prop.id === id) const response = await apiClient.post(`/card/templates/${id}/publish/`)
const data = response.data?.template || response.data
if (propIndex === -1) { return mapBackendProp(data)
return mockResponse({} as Prop, "道具不存在")
} }
mockProps[propIndex].status = "已发布" export const archiveProp = async (id: string): Promise<Prop> => {
mockProps[propIndex].releaseDate = new Date().toISOString().split("T")[0] const response = await apiClient.post(`/card/templates/${id}/archive/`)
const data = response.data?.template || response.data
return mockResponse(mockProps[propIndex]) return mapBackendProp(data)
} }

View File

@ -1,177 +1,70 @@
import type { Role } from "./types" import type { Role } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client" import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟角色数据 // 将后端 Django Group 数据映射到前端 Role 类型
const mockRoles: Role[] = [ function mapBackendGroup(g: any): Role {
{ return {
id: "1", id: String(g.id),
name: "超级管理员", name: g.name,
description: "拥有系统所有权限", description: undefined,
permissions: ["all"], permissions: g.permissions || [],
userCount: 1, userCount: g.user_count || 0,
createdAt: "2023-01-01", createdAt: undefined,
updatedAt: "2023-01-01", updatedAt: undefined,
}, }
{ }
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",
},
]
// 获取角色列表 // 获取角色(Group)列表
export const getRoles = async (params?: PaginationParams): Promise<PaginatedResponse<Role>> => { 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 page = params?.page || 1
const pageSize = params?.pageSize || 10 const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize
const paginatedRoles = filteredRoles.slice(startIndex, startIndex + pageSize)
return mockResponse({ const response = await apiClient.get(`/user/groups/?page=${page}&page_size=${pageSize}`)
items: paginatedRoles,
total: filteredRoles.length, 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, page,
pageSize, pageSize,
totalPages: Math.ceil(filteredRoles.length / pageSize), totalPages: Math.ceil(total / pageSize),
}) }
} }
// 获取单个角色 // 获取单个角色
export const getRole = async (id: string): Promise<Role> => { export const getRole = async (id: string): Promise<Role> => {
const role = mockRoles.find((role) => role.id === id) const response = await apiClient.get(`/user/groups/${id}/`)
const data = response.data?.data || response.data
if (!role) { return mapBackendGroup(data)
return mockResponse({} as Role, "角色不存在")
} }
return mockResponse(role) // 创建角色(需要管理员权限)
}
// 创建角色
export const createRole = async (roleData: Partial<Role>): Promise<Role> => { export const createRole = async (roleData: Partial<Role>): Promise<Role> => {
// 检查角色名是否已存在 const payload = {
if (mockRoles.some((role) => role.name === roleData.name)) { name: roleData.name,
return mockResponse({} as Role, "该角色名已存在")
} }
const newRole: Role = { const response = await apiClient.post(`/user/groups/`, payload)
id: String(mockRoles.length + 1), const data = response.data?.data || response.data
name: roleData.name || "", return mapBackendGroup(data)
description: roleData.description,
permissions: roleData.permissions || [],
userCount: 0,
createdAt: new Date().toISOString().split("T")[0],
updatedAt: new Date().toISOString().split("T")[0],
} }
mockRoles.push(newRole) // 更新角色(需要管理员权限)
return mockResponse(newRole)
}
// 更新角色
export const updateRole = async (id: string, roleData: Partial<Role>): Promise<Role> => { 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) { const response = await apiClient.patch(`/user/groups/${id}/`, payload)
return mockResponse({} as Role, "角色不存在") 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> => { export const deleteRole = async (id: string): Promise<boolean> => {
const roleIndex = mockRoles.findIndex((role) => role.id === id) await apiClient.delete(`/user/groups/${id}/`)
return true
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)
} }

View File

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

View File

@ -34,18 +34,18 @@ export const uploadFile = async (
const response = await apiClient.post('/common/upload/', formData, { const response = await apiClient.post('/common/upload/', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': undefined as any, // Let axios/browser set multipart boundary automatically
}, },
onUploadProgress: (progressEvent) => { onUploadProgress: (progressEvent) => {
const { loaded, total } = progressEvent; const { loaded, total } = progressEvent;
const percentage = total ? Math.round((loaded * 100) / total) : 0; 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) { if (onProgress) {
onProgress({ onProgress({
loaded, loaded,
total, total: total || 0,
percentage, percentage,
}); });
} }
@ -84,7 +84,7 @@ export const uploadFiles = async (
const response = await apiClient.post('/common/upload/', formData, { const response = await apiClient.post('/common/upload/', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': undefined as any, // Let axios/browser set multipart boundary automatically
}, },
onUploadProgress: (progressEvent) => { onUploadProgress: (progressEvent) => {
const { loaded, total } = progressEvent; const { loaded, total } = progressEvent;
@ -95,7 +95,7 @@ export const uploadFiles = async (
if (onProgress) { if (onProgress) {
onProgress({ onProgress({
loaded, loaded,
total, total: total || 0,
percentage, percentage,
}); });
} }

View File

@ -1,225 +1,100 @@
import type { User, LoginHistory } from "./types" import type { User, LoginHistory } from "./types"
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client" import { apiClient } from "./client"
import type { PaginatedResponse, PaginationParams } from "./client"
// 模拟用户数据 // 将后端 ParadiseUser 数据映射到前端 User 类型
const mockUsers: User[] = [ function mapBackendUser(u: any): User {
{ return {
id: "1", id: String(u.id),
name: "管理员", name: u.username || u.email || u.phone_number || "",
email: "admin@example.com", email: u.email || "",
role: "超级管理员", role: u.is_superuser ? "超级管理员" : u.is_staff ? "管理员" : "普通用户",
status: "活跃", status: u.is_active ? "活跃" : "未激活",
registeredAt: "2023-01-01", registeredAt: u.date_joined ? u.date_joined.split("T")[0] : "",
lastLogin: "今天 08:45", lastLogin: u.last_login ? u.last_login.split("T")[0] : undefined,
phone: "13800138000", phone: u.phone_number || undefined,
address: "北京市海淀区中关村", address: u.resident_city || undefined,
permissions: [ avatar: undefined,
"仪表盘查看", permissions: [],
"用户管理", loginHistory: [],
"角色权限管理", }
"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: ["仪表盘查看"],
},
]
// 获取用户列表 // 获取用户列表
export const getUsers = async (params?: PaginationParams): Promise<PaginatedResponse<User>> => { 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 page = params?.page || 1
const pageSize = params?.pageSize || 10 const pageSize = params?.pageSize || 10
const startIndex = (page - 1) * pageSize const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
const paginatedUsers = filteredUsers.slice(startIndex, startIndex + pageSize)
return mockResponse({ const response = await apiClient.get(`/user/?page=${page}&page_size=${pageSize}${searchParam}`)
items: paginatedUsers, const data = response.data
total: filteredUsers.length,
// 后端直接返回分页数据DRF 默认格式)
const results: any[] = data.results || data
const total = data.count || results.length
return {
items: results.map(mapBackendUser),
total,
page, page,
pageSize, pageSize,
totalPages: Math.ceil(filteredUsers.length / pageSize), totalPages: Math.ceil(total / pageSize),
}) }
} }
// 获取单个用户 // 获取单个用户
export const getUser = async (id: string): Promise<User> => { export const getUser = async (id: string): Promise<User> => {
const user = mockUsers.find((user) => user.id === id) const response = await apiClient.get(`/user/${id}/`)
return mapBackendUser(response.data)
if (!user) {
return mockResponse({} as User, "用户不存在")
}
return mockResponse(user)
} }
// 创建用户 // 创建用户
export const createUser = async (userData: Partial<User>): Promise<User> => { export const createUser = async (userData: Partial<User> & { password?: string }): Promise<User> => {
// 检查邮箱是否已存在 const payload: any = {
if (mockUsers.some((user) => user.email === userData.email)) { username: userData.name,
return mockResponse({} as User, "该邮箱已被注册") 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 response = await apiClient.post(`/user/`, payload)
const newUser: User = { return mapBackendUser(response.data)
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)
} }
// 更新用户 // 更新用户
export const updateUser = async (id: string, userData: Partial<User>): Promise<User> => { export const updateUser = async (id: string, userData: Partial<User>): Promise<User> => {
const userIndex = mockUsers.findIndex((user) => user.id === id) const payload: any = {}
if (userData.name !== undefined) payload.username = userData.name
if (userIndex === -1) { if (userData.email !== undefined) payload.email = userData.email
return mockResponse({} as User, "用户不存在") 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 === "超级管理员"
} }
// 检查邮箱是否已被其他用户使用 const response = await apiClient.patch(`/user/${id}/`, payload)
if (userData.email && userData.email !== mockUsers[userIndex].email) { return mapBackendUser(response.data)
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)
} }
// 删除用户 // 删除用户
export const deleteUser = async (id: string): Promise<boolean> => { export const deleteUser = async (id: string): Promise<boolean> => {
const userIndex = mockUsers.findIndex((user) => user.id === id) await apiClient.delete(`/user/${id}/`)
return true
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 || [])
} }
// 更改用户状态 // 更改用户状态
export const changeUserStatus = async (id: string, status: User["status"]): Promise<User> => { export const changeUserStatus = async (id: string, status: User["status"]): Promise<User> => {
const userIndex = mockUsers.findIndex((user) => user.id === id) const response = await apiClient.patch(`/user/${id}/`, {
is_active: status === "活跃",
if (userIndex === -1) { })
return mockResponse({} as User, "用户不存在") return mapBackendUser(response.data)
} }
mockUsers[userIndex].status = status // 获取用户登录历史(后端暂无此接口,返回空数组)
export const getUserLoginHistory = async (_id: string): Promise<LoginHistory[]> => {
return mockResponse(mockUsers[userIndex]) 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": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -257,9 +254,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -276,9 +270,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -295,9 +286,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -314,9 +302,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -333,9 +318,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -352,9 +334,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -377,9 +356,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -402,9 +378,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -427,9 +400,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -452,9 +422,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -477,9 +444,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [

View File

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

View File

@ -15,13 +15,21 @@ from .serializers import (
UserAchievementListSerializer 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 serializer_class = AchievementSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [IsAdminOrReadOnly]
authentication_classes = [RedisTokenAuthentication] authentication_classes = [RedisTokenAuthentication]
def get_queryset(self): def get_queryset(self):

View File

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

View File

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

View File

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

View File

@ -8,6 +8,8 @@ from .oss import OSSUploader
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi from drf_yasg import openapi
import logging import logging
import traceback
import os
from userapp.authentication import RedisTokenAuthentication from userapp.authentication import RedisTokenAuthentication
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -70,22 +72,42 @@ def upload_file(request):
try: try:
# 检查是否有文件 # 检查是否有文件
if 'file' not in request.FILES: 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) return Response({"error": "No file provided"}, status=status.HTTP_400_BAD_REQUEST)
file_obj = request.FILES['file'] 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) filename = request.data.get('filename', None)
# 上传文件 # 上传文件
uploader = OSSUploader() uploader = OSSUploader()
result = uploader.upload_file(file_obj, is_permanent, filename) 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: 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( return Response(
{"error": "File upload failed", "detail": str(e)}, {"error": "File upload failed", "detail": str(e)},
status=status.HTTP_400_BAD_REQUEST 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') food_type_display = serializers.ReadOnlyField(source='get_food_type_display')
is_available = serializers.ReadOnlyField() is_available = serializers.ReadOnlyField()
status_display = serializers.ReadOnlyField(source='get_status_display')
class Meta: class Meta:
model = Food model = Food
fields = [ fields = [
'id', 'name', 'food_type', 'food_type_display', 'rarity', 'id', 'name', 'food_type', 'food_type_display', 'rarity',
'rarity_display', 'image', 'calories', 'is_available', '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): 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') queryset = Food.objects.filter(status='published')
else: else:
# 对于创建、更新、删除操作,显示所有食物
queryset = Food.objects.all() queryset = Food.objects.all()
# 筛选参数 # 筛选参数
@ -86,6 +86,57 @@ class FoodViewSet(viewsets.ModelViewSet):
'statuses': Food.STATUS_CHOICES '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): class UserFoodViewSet(viewsets.ReadOnlyModelViewSet):
""" """

View File

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

View File

@ -8,6 +8,9 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RedisTokenAuthentication(BaseAuthentication): class RedisTokenAuthentication(BaseAuthentication):
def authenticate_header(self, request):
return 'Bearer realm="api"'
def authenticate(self, request): def authenticate(self, request):
authorization = request.headers.get('Authorization') authorization = request.headers.get('Authorization')
if not 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', # related_name='auth_token',
# on_delete=models.CASCADE # 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 rest_framework.routers import DefaultRouter
from .views import ( from .views import (
ParadiseUserViewSet, CustomRegisterView, PhoneLoginView, ParadiseUserViewSet, CustomRegisterView, PhoneLoginView,
SendVerifyCodeView, EmailLoginView, UsernameLoginView, MacAddressLoginView SendVerifyCodeView, EmailLoginView, UsernameLoginView, MacAddressLoginView,
GroupViewSet, AffinityRuleViewSet, AffinityLevelViewSet
) )
router = DefaultRouter() 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) router.register('', ParadiseUserViewSet)
# 用户视图集的登录相关路径 # 用户视图集的登录相关路径

View File

@ -1,9 +1,10 @@
from dj_rest_auth.registration.views import RegisterView from dj_rest_auth.registration.views import RegisterView
from rest_framework import viewsets from rest_framework import viewsets
from .models import ParadiseUser from .models import ParadiseUser, AffinityRule, AffinityLevel
from device_interaction.models import Device, UserDevice from device_interaction.models import Device, UserDevice
from .serializers import ParadiseUserSerializer, CustomRegisterSerializer, UserInfoSerializer, ProfileUpdateSerializer from .serializers import ParadiseUserSerializer, CustomRegisterSerializer, UserInfoSerializer, ProfileUpdateSerializer
from rest_framework import viewsets, status from rest_framework import viewsets, status
from django.contrib.auth.models import Group, Permission
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.decorators import action, permission_classes, authentication_classes from rest_framework.decorators import action, permission_classes, authentication_classes
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
@ -440,6 +441,93 @@ class BindPhoneRequestSchema(serializers.Serializer):
phone_number = serializers.CharField(required=True, help_text="手机号码") phone_number = serializers.CharField(required=True, help_text="手机号码")
code = 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): class ParadiseUserViewSet(viewsets.ModelViewSet):
""" """
用户管理接口 用户管理接口
@ -642,7 +730,18 @@ class AdminEmailLoginView(APIView):
# 使用is_admin=True生成管理员专用token # 使用is_admin=True生成管理员专用token
token = generate_token(user.id, is_admin=True) token = generate_token(user.id, is_admin=True)
logger.info(f"Admin logged in with email: {email}") 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: except ParadiseUser.DoesNotExist:
pass pass