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:
parent
0c610c1e49
commit
bd95ba470c
122
.claude/settings.json
Normal file
122
.claude/settings.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
340
docs/功能清单与开发状态.md
Normal file
340
docs/功能清单与开发状态.md
Normal 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. **工作流系统** — 完全空白,需要完整设计
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, use } from "react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
@ -12,6 +12,7 @@ import Link from "next/link"
|
||||
import { AddPrintBatchDialog } from "@/components/food/add-print-batch-dialog"
|
||||
import { ExportCardsDialog } from "@/components/food/export-cards-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { isSuperUser } from "@/lib/api/auth"
|
||||
import { getFood } from "@/lib/api/food"
|
||||
import type { Food } from "@/components/food/food-detail-dialog"
|
||||
|
||||
@ -28,7 +29,8 @@ type FoodWithBatches = Food & {
|
||||
}>
|
||||
}
|
||||
|
||||
export default function FoodDetailPage({ params }: { params: { id: string } }) {
|
||||
export default function FoodDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const { toast } = useToast()
|
||||
const [food, setFood] = useState<FoodWithBatches | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -39,7 +41,7 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await getFood(params.id)
|
||||
const response = await getFood(id)
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 为演示目的,添加一些模拟的批次数据
|
||||
@ -84,7 +86,7 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchFoodDetail()
|
||||
}, [params.id])
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -104,7 +106,7 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
|
||||
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">食物不存在</h1>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{error || `找不到ID为 ${params.id} 的食物`}
|
||||
{error || `找不到ID为 ${id} 的食物`}
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/food">
|
||||
@ -117,7 +119,21 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
|
||||
)
|
||||
}
|
||||
|
||||
const isPublished = food.status === "已发布"
|
||||
const isPublished = food.status === "published" || food.status === "已发布"
|
||||
|
||||
// 显示名称映射
|
||||
const foodTypeDisplayMap: Record<string, string> = {
|
||||
fruit: "水果", vegetable: "蔬菜", meat: "肉类", seafood: "海鲜",
|
||||
dairy: "乳制品", grain: "谷物", snack: "零食", drink: "饮品",
|
||||
dessert: "甜点", spice: "调味品", other: "其他",
|
||||
}
|
||||
const rarityDisplayMap: Record<string, string> = {
|
||||
common: "普通", uncommon: "非常见", rare: "稀有",
|
||||
epic: "史诗", legendary: "传说", mythic: "神话",
|
||||
}
|
||||
const statusDisplayMap: Record<string, string> = {
|
||||
draft: "草稿", published: "已发布", archived: "已归档",
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
@ -132,12 +148,12 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
|
||||
</Button>
|
||||
<DashboardHeader heading={food.name} text={`食物ID: ${food.id}`}>
|
||||
<div className="flex space-x-2 ml-auto">
|
||||
{!isPublished && (
|
||||
{(!isPublished || isSuperUser()) && (
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Link href={`/food/edit/${params.id}`}>
|
||||
<Link href={`/food/edit/${id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑食物
|
||||
</Link>
|
||||
@ -163,9 +179,18 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<div className="relative w-full aspect-square max-w-[300px] rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
|
||||
<img src={food.image || "/placeholder.svg"} alt={food.name} className="object-cover" />
|
||||
{food.image && !food.image.includes("placeholder") ? (
|
||||
<img src={food.image} alt={food.name} className="object-cover w-full h-full" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-gray-400">
|
||||
<svg className="h-16 w-16 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
||||
</svg>
|
||||
<span className="text-sm">暂无图片</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3">
|
||||
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{food.status}</Badge>
|
||||
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{statusDisplayMap[food.status] || food.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -179,11 +204,11 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">类型</p>
|
||||
<p className="font-medium">{food.type}</p>
|
||||
<p className="font-medium">{foodTypeDisplayMap[food.food_type] || food.food_type || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">稀有度</p>
|
||||
<p className="font-medium">{food.rarity}</p>
|
||||
<p className="font-medium">{rarityDisplayMap[food.rarity] || food.rarity || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">发布日期</p>
|
||||
@ -191,11 +216,11 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">状态</p>
|
||||
<p className="font-medium">{food.status}</p>
|
||||
<p className="font-medium">{statusDisplayMap[food.status] || food.status}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">激活数量</p>
|
||||
<p className="font-medium">{food.activatedCount}</p>
|
||||
<p className="font-medium">{food.activatedCount || 0}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">印刷总数</p>
|
||||
@ -203,14 +228,18 @@ export default function FoodDetailPage({ params }: { params: { id: string } }) {
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">描述</p>
|
||||
<p className="font-medium">{food.description}</p>
|
||||
<p className="font-medium">{food.description || "暂无描述"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPublished && (
|
||||
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-700">该食物已发布,基本属性不可修改。您仍可以增加印刷数量。</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
{isSuperUser()
|
||||
? "该食物已发布,您以超级管理员身份仍可编辑和删除。请谨慎操作。"
|
||||
: "该食物已发布,基本属性不可修改。您仍可以增加印刷数量。"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@ -8,13 +8,15 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Edit, Eye, Loader2 } from "lucide-react"
|
||||
import { Search, Edit, Eye, Loader2, Archive } from "lucide-react"
|
||||
import { AddFoodDialog } from "@/components/food/add-food-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { isSuperUser } from "@/lib/api/auth"
|
||||
import Link from "next/link"
|
||||
import type { Food } from "@/components/food/food-detail-dialog"
|
||||
import { getFoods, deleteFood as deleteFoodApi } from "@/lib/api/food"
|
||||
import { getFoods, getFood, deleteFood as deleteFoodApi, publishFood, archiveFood } from "@/lib/api/food"
|
||||
|
||||
export default function FoodPage() {
|
||||
const { toast } = useToast()
|
||||
@ -35,6 +37,7 @@ export default function FoodPage() {
|
||||
'published': '已发布',
|
||||
'draft': '草稿',
|
||||
'pending': '待审核',
|
||||
'archived': '已归档',
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
@ -117,9 +120,57 @@ export default function FoodPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEditDialog = (food: Food) => {
|
||||
setSelectedFood(food)
|
||||
// 发布食物
|
||||
const handlePublishFood = async (foodId: string, foodName: string) => {
|
||||
try {
|
||||
await publishFood(foodId)
|
||||
fetchFoods(currentPage, searchTerm)
|
||||
toast({
|
||||
title: "发布成功",
|
||||
description: `食物 "${foodName}" 已成功发布`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("发布食物失败:", error)
|
||||
toast({
|
||||
title: "发布失败",
|
||||
description: "无法发布食物,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 归档食物
|
||||
const handleArchiveFood = async (foodId: string, foodName: string) => {
|
||||
try {
|
||||
await archiveFood(foodId)
|
||||
fetchFoods(currentPage, searchTerm)
|
||||
toast({
|
||||
title: "归档成功",
|
||||
description: `食物 "${foodName}" 已归档`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("归档食物失败:", error)
|
||||
toast({
|
||||
title: "归档失败",
|
||||
description: "无法归档食物,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框(先获取完整详情)
|
||||
const openEditDialog = async (food: Food) => {
|
||||
try {
|
||||
const response = await getFood(String(food.id))
|
||||
if (response.success && response.data) {
|
||||
setSelectedFood(response.data)
|
||||
} else {
|
||||
setSelectedFood(food)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取食物详情失败:", error)
|
||||
setSelectedFood(food)
|
||||
}
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
@ -177,15 +228,17 @@ export default function FoodPage() {
|
||||
<TableRow key={food.id} className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">{food.id}</TableCell>
|
||||
<TableCell className="font-medium text-pink-600">{food.name}</TableCell>
|
||||
<TableCell>{food.food_type}</TableCell>
|
||||
<TableCell>{food.rarity}</TableCell>
|
||||
<TableCell>{food.food_type_display || food.food_type}</TableCell>
|
||||
<TableCell>{food.rarity_display || food.rarity}</TableCell>
|
||||
<TableCell>{food.releaseDate || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
(food.status === "published" || food.status === "已发布")
|
||||
? "bg-green-500 hover:bg-green-600"
|
||||
: "bg-gray-500 hover:bg-gray-600"
|
||||
(food.status === "published" || food.status === "已发布")
|
||||
? "bg-green-500 hover:bg-green-600"
|
||||
: (food.status === "archived" || food.status === "已归档")
|
||||
? "bg-orange-500 hover:bg-orange-600"
|
||||
: "bg-gray-500 hover:bg-gray-600"
|
||||
}
|
||||
>
|
||||
{getStatusDisplay(food.status)}
|
||||
@ -200,7 +253,30 @@ export default function FoodPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{food.status !== "published" && food.status !== "已发布" && (
|
||||
{/* 草稿状态:显示发布按钮 */}
|
||||
{food.status === "draft" && (
|
||||
<PublishConfirmationDialog
|
||||
title="发布食物"
|
||||
description="发布后该食物将可被用于卡牌生成"
|
||||
itemName={food.name}
|
||||
onPublish={() => handlePublishFood(food.id, food.name)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 已发布状态:显示归档按钮 */}
|
||||
{food.status === "published" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-orange-50 hover:text-orange-600"
|
||||
title="归档"
|
||||
onClick={() => handleArchiveFood(food.id, food.name)}
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(food.status !== "published" || isSuperUser()) && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@ -1,146 +1,61 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, use } from "react"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ArrowLeft, Edit, AlertTriangle, FileText } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ArrowLeft, Edit, AlertTriangle, Plus, Download, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { AddPrintBatchDialog } from "@/components/home-decor/add-print-batch-dialog"
|
||||
import { ExportCardsDialog } from "@/components/home-decor/export-cards-dialog"
|
||||
import { isSuperUser } from "@/lib/api/auth"
|
||||
import { getHomeDecor } from "@/lib/api/home-decor"
|
||||
import type { HomeDecor } from "@/lib/api/types"
|
||||
|
||||
// Mock data for the home decor details
|
||||
const decorData = {
|
||||
DEC001: {
|
||||
id: "DEC001",
|
||||
name: "星空投影灯",
|
||||
type: "灯饰",
|
||||
rarity: "稀有",
|
||||
description: "可以在房间内投影出美丽的星空,营造浪漫氛围。",
|
||||
releaseDate: "2023-10-20",
|
||||
status: "已发布",
|
||||
activatedCount: 1342,
|
||||
printedCount: 2500,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{
|
||||
id: "B001",
|
||||
date: "2023-09-01",
|
||||
quantity: 1500,
|
||||
startId: "DEC001-0001",
|
||||
endId: "DEC001-1500",
|
||||
activatedCount: 842,
|
||||
},
|
||||
{
|
||||
id: "B002",
|
||||
date: "2023-12-15",
|
||||
quantity: 1000,
|
||||
startId: "DEC001-1501",
|
||||
endId: "DEC001-2500",
|
||||
activatedCount: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
DEC002: {
|
||||
id: "DEC002",
|
||||
name: "音乐主题壁纸",
|
||||
type: "墙饰",
|
||||
rarity: "普通",
|
||||
description: "以音乐元素为主题的壁纸,适合洛天依的房间装饰。",
|
||||
releaseDate: "2023-11-05",
|
||||
status: "已发布",
|
||||
activatedCount: 2156,
|
||||
printedCount: 3000,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{
|
||||
id: "B003",
|
||||
date: "2023-10-10",
|
||||
quantity: 3000,
|
||||
startId: "DEC002-0001",
|
||||
endId: "DEC002-3000",
|
||||
activatedCount: 2156,
|
||||
},
|
||||
],
|
||||
},
|
||||
DEC003: {
|
||||
id: "DEC003",
|
||||
name: "音符地毯",
|
||||
type: "地饰",
|
||||
rarity: "稀有",
|
||||
description: "音符形状的地毯,踩上去会发出悦耳的音符声。",
|
||||
releaseDate: "2023-12-15",
|
||||
status: "已发布",
|
||||
activatedCount: 987,
|
||||
printedCount: 2000,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{
|
||||
id: "B004",
|
||||
date: "2023-11-20",
|
||||
quantity: 2000,
|
||||
startId: "DEC003-0001",
|
||||
endId: "DEC003-2000",
|
||||
activatedCount: 987,
|
||||
},
|
||||
],
|
||||
},
|
||||
DEC004: {
|
||||
id: "DEC004",
|
||||
name: "全息投影装置",
|
||||
type: "科技装饰",
|
||||
rarity: "传说",
|
||||
description: "可以投影出洛天依的全息影像,实现虚拟互动。",
|
||||
releaseDate: "2024-01-20",
|
||||
status: "已发布",
|
||||
activatedCount: 456,
|
||||
printedCount: 1000,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{
|
||||
id: "B005",
|
||||
date: "2024-01-05",
|
||||
quantity: 1000,
|
||||
startId: "DEC004-0001",
|
||||
endId: "DEC004-1000",
|
||||
activatedCount: 456,
|
||||
},
|
||||
],
|
||||
},
|
||||
DEC005: {
|
||||
id: "DEC005",
|
||||
name: "樱花主题家具套装",
|
||||
type: "家具套装",
|
||||
rarity: "史诗",
|
||||
description: "以樱花为主题的家具套装,包含床、桌椅、柜子等多件家具。",
|
||||
releaseDate: "",
|
||||
status: "未发布",
|
||||
activatedCount: 0,
|
||||
printedCount: 1500,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{
|
||||
id: "B006",
|
||||
date: "2024-02-10",
|
||||
quantity: 1500,
|
||||
startId: "DEC005-0001",
|
||||
endId: "DEC005-1500",
|
||||
activatedCount: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
export default function HomeDecorDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const [decor, setDecor] = useState<HomeDecor | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
export default function HomeDecorDetailPage({ params }: { params: { id: string } }) {
|
||||
const decor = decorData[params.id as keyof typeof decorData]
|
||||
useEffect(() => {
|
||||
const fetchDecor = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await getHomeDecor(id)
|
||||
setDecor(data)
|
||||
} catch (err) {
|
||||
console.error("获取家居装饰详情失败:", err)
|
||||
setError(`找不到ID为 ${id} 的家居装饰`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchDecor()
|
||||
}, [id])
|
||||
|
||||
if (!decor) {
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
|
||||
<span className="ml-2 text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !decor) {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex flex-col items-center justify-center h-[60vh]">
|
||||
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">家居装饰不存在</h1>
|
||||
<p className="text-gray-500 mb-6">找不到ID为 {params.id} 的家居装饰</p>
|
||||
<p className="text-gray-500 mb-6">{error || `找不到ID为 ${id} 的家居装饰`}</p>
|
||||
<Button asChild>
|
||||
<Link href="/home-decor">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
@ -153,6 +68,9 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
|
||||
}
|
||||
|
||||
const isPublished = decor.status === "已发布"
|
||||
const printedCount = decor.batchesCount || 0
|
||||
const activatedCount = decor.activeCardsCount || 0
|
||||
const activationRate = printedCount > 0 ? Math.round((activatedCount / printedCount) * 100) : 0
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
@ -167,12 +85,12 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
|
||||
</Button>
|
||||
<DashboardHeader heading={decor.name} text={`家居装饰ID: ${decor.id}`}>
|
||||
<div className="flex space-x-2 ml-auto">
|
||||
{!isPublished && (
|
||||
{(!isPublished || isSuperUser()) && (
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Link href={`/home-decor/edit/${params.id}`}>
|
||||
<Link href={`/home-decor/edit/${id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑家居装饰
|
||||
</Link>
|
||||
@ -183,14 +101,14 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
|
||||
</DashboardHeader>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="details" className="space-y-4">
|
||||
<TabsList>
|
||||
<Tabs defaultValue="details" className="w-full">
|
||||
<TabsList className="grid w-full md:w-auto grid-cols-2 md:grid-cols-3 mb-4">
|
||||
<TabsTrigger value="details">家居装饰详情</TabsTrigger>
|
||||
<TabsTrigger value="batches">批次管理</TabsTrigger>
|
||||
<TabsTrigger value="analytics">数据分析</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<TabsContent value="details" className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1 border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
@ -198,9 +116,18 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<div className="relative w-full aspect-square max-w-[300px] rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
|
||||
<img src={decor.image || "/placeholder.svg"} alt={decor.name} className="object-cover" />
|
||||
{decor.imageUrl && !decor.imageUrl.includes("placeholder") ? (
|
||||
<img src={decor.imageUrl} alt={decor.name} className="object-cover w-full h-full" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-gray-400">
|
||||
<svg className="h-16 w-16 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
||||
</svg>
|
||||
<span className="text-sm">暂无图片</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3">
|
||||
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{decor.status}</Badge>
|
||||
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{decor.status || "未发布"}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -213,39 +140,44 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">类型</p>
|
||||
<p className="font-medium">{decor.type}</p>
|
||||
<p className="text-sm font-medium text-gray-500">装饰类型</p>
|
||||
<p className="font-medium">{decor.category || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">稀有度</p>
|
||||
<p className="font-medium">{decor.rarity}</p>
|
||||
<p className="font-medium">{decor.rarity || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">发布日期</p>
|
||||
<p className="font-medium">{decor.releaseDate || "尚未发布"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">状态</p>
|
||||
<p className="font-medium">{decor.status}</p>
|
||||
<p className="font-medium">{decor.publishedAt || "尚未发布"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">激活数量</p>
|
||||
<p className="font-medium">{decor.activatedCount}</p>
|
||||
<p className="font-medium">{activatedCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">印刷总数</p>
|
||||
<p className="font-medium">{decor.printedCount}</p>
|
||||
<p className="text-sm font-medium text-gray-500">创建日期</p>
|
||||
<p className="font-medium">{decor.createdAt || "-"}</p>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">描述</p>
|
||||
<p className="font-medium">{decor.description}</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">激活率</p>
|
||||
<p className="font-medium">{activationRate}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">装饰描述</p>
|
||||
<p className="text-gray-700">{decor.description || "暂无描述"}</p>
|
||||
</div>
|
||||
|
||||
{isPublished && (
|
||||
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-700">该家居装饰已发布,基本属性不可修改。您仍可以增加印刷数量。</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
{isSuperUser()
|
||||
? "该家居装饰已发布,您以超级管理员身份仍可编辑和删除。请谨慎操作。"
|
||||
: "该家居装饰已发布,基本属性不可修改。您仍可以增加印刷数量。"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@ -253,11 +185,11 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batches" className="space-y-4">
|
||||
<TabsContent value="batches" className="space-y-6">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold">印刷批次</CardTitle>
|
||||
<CardTitle className="text-lg font-bold">印刷批次管理</CardTitle>
|
||||
<CardDescription>管理家居装饰卡牌的印刷批次和卡牌ID</CardDescription>
|
||||
</div>
|
||||
<AddPrintBatchDialog decorId={decor.id} isPublished={isPublished} />
|
||||
@ -270,57 +202,79 @@ export default function HomeDecorDetailPage({ params }: { params: { id: string }
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">批次ID</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">创建日期</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">数量</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">起始ID</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">结束ID</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">激活数量</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">状态</th>
|
||||
<th className="py-3 px-4 text-right text-sm font-medium text-gray-500">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{decor.batches.map((batch) => (
|
||||
<tr key={batch.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 text-sm font-medium">{batch.id}</td>
|
||||
<td className="py-3 px-4 text-sm">{batch.date}</td>
|
||||
<td className="py-3 px-4 text-sm">{batch.quantity}</td>
|
||||
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.startId}</td>
|
||||
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.endId}</td>
|
||||
<td className="py-3 px-4 text-sm">{batch.activatedCount}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<Button variant="ghost" size="sm" className="h-8 hover:bg-blue-50 hover:text-blue-600">
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
导出ID
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||
批次数据将从后端加载
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics" className="space-y-4">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-green-50">
|
||||
<Card className="border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">数据分析</CardTitle>
|
||||
<CardDescription>查看家居装饰卡牌的激活数据和使用情况</CardDescription>
|
||||
<CardTitle className="text-lg font-bold">批次操作</CardTitle>
|
||||
<CardDescription>批量管理卡牌批次</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<p className="text-gray-500">激活数据图表</p>
|
||||
</div>
|
||||
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<p className="text-gray-500">地区分布图表</p>
|
||||
</div>
|
||||
<div className="md:col-span-2 h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<p className="text-gray-500">时间趋势图表</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button variant="outline" className="border-blue-200 hover:bg-blue-50 hover:text-blue-700">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出所有批次
|
||||
</Button>
|
||||
<Button variant="outline" className="border-purple-200 hover:bg-purple-50 hover:text-purple-700">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
批量添加批次
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics" className="space-y-6">
|
||||
<Card className="border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">激活数据分析</CardTitle>
|
||||
<CardDescription>家居装饰卡牌激活情况统计</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">激活数据图表将在此显示</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-green-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">地区分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px] flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">地区分布图表将在此显示</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">时间趋势</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px] flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">时间趋势图表将在此显示</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@ -8,96 +8,90 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Edit, Eye } from "lucide-react"
|
||||
import { Search, Edit, Eye, Loader2, Archive } from "lucide-react"
|
||||
import { AddHomeDecorDialog } from "@/components/home-decor/add-home-decor-dialog"
|
||||
import type { HomeDecor as ComponentHomeDecor } from "@/components/home-decor/home-decor-detail-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { isSuperUser } from "@/lib/api/auth"
|
||||
import { getHomeDecors, deleteHomeDecor, publishHomeDecor, archiveHomeDecor } from "@/lib/api/home-decor"
|
||||
import type { HomeDecor } from "@/lib/api/types"
|
||||
import Link from "next/link"
|
||||
import type { HomeDecor } from "@/components/home-decor/home-decor-detail-dialog"
|
||||
|
||||
// 初始家居装饰数据
|
||||
const initialDecors: HomeDecor[] = [
|
||||
{
|
||||
id: "DEC001",
|
||||
name: "星空投影灯",
|
||||
type: "灯饰",
|
||||
rarity: "稀有",
|
||||
description: "可以在房间内投影出美丽的星空,营造浪漫氛围。",
|
||||
releaseDate: "2023-10-20",
|
||||
status: "已发布",
|
||||
activatedCount: 1342,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "DEC002",
|
||||
name: "音乐主题壁纸",
|
||||
type: "墙饰",
|
||||
rarity: "普通",
|
||||
description: "以音乐元素为主题的壁纸,适合洛天依的房间装饰。",
|
||||
releaseDate: "2023-11-05",
|
||||
status: "已发布",
|
||||
activatedCount: 2156,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "DEC003",
|
||||
name: "音符地毯",
|
||||
type: "地饰",
|
||||
rarity: "稀有",
|
||||
description: "音符形状的地毯,踩上去会发出悦耳的音符声。",
|
||||
releaseDate: "2023-12-15",
|
||||
status: "已发布",
|
||||
activatedCount: 987,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "DEC004",
|
||||
name: "全息投影装置",
|
||||
type: "科技装饰",
|
||||
rarity: "传说",
|
||||
description: "可以投影出洛天依的全息影像,实现虚拟互动。",
|
||||
releaseDate: "2024-01-20",
|
||||
status: "已发布",
|
||||
activatedCount: 456,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "DEC005",
|
||||
name: "樱花主题家具套装",
|
||||
type: "家具套装",
|
||||
rarity: "史诗",
|
||||
description: "以樱花为主题的家具套装,包含床、桌椅、柜子等多件家具。",
|
||||
releaseDate: "",
|
||||
status: "未发布",
|
||||
activatedCount: 0,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
]
|
||||
// 格式化日期时间
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return ""
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// 将 API HomeDecor 转换为组件显示用的 HomeDecor
|
||||
function toDisplayDecor(decor: HomeDecor): ComponentHomeDecor {
|
||||
return {
|
||||
id: decor.id,
|
||||
name: decor.name,
|
||||
type: decor.category || "",
|
||||
rarity: decor.rarity || "",
|
||||
description: decor.description || "",
|
||||
releaseDate: formatDate(decor.publishedAt || decor.createdAt || ""),
|
||||
status: decor.status || "未发布",
|
||||
activatedCount: decor.activeCardsCount || 0,
|
||||
image: decor.imageUrl || "/placeholder.svg?height=300&width=300",
|
||||
}
|
||||
}
|
||||
|
||||
export default function HomeDecorPage() {
|
||||
const { toast } = useToast()
|
||||
const [decors, setDecors] = useState<HomeDecor[]>(initialDecors)
|
||||
const [decors, setDecors] = useState<ComponentHomeDecor[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [selectedDecor, setSelectedDecor] = useState<HomeDecor | null>(null)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [selectedDecor, setSelectedDecor] = useState<ComponentHomeDecor | null>(null)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const itemsPerPage = 5
|
||||
const itemsPerPage = 10
|
||||
|
||||
// 过滤和分页
|
||||
const filteredDecors = decors.filter(
|
||||
(decor) =>
|
||||
decor.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
decor.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
decor.type.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
// 从后端获取家居装饰列表
|
||||
const fetchDecors = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getHomeDecors({
|
||||
page: currentPage,
|
||||
pageSize: itemsPerPage,
|
||||
search: searchTerm || undefined,
|
||||
})
|
||||
setDecors(response.items.map(toDisplayDecor))
|
||||
setTotalItems(response.total)
|
||||
} catch (error) {
|
||||
console.error("获取家居装饰列表失败:", error)
|
||||
toast({
|
||||
title: "获取失败",
|
||||
description: "无法获取家居装饰列表,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [currentPage, searchTerm, toast])
|
||||
|
||||
const totalPages = Math.ceil(filteredDecors.length / itemsPerPage)
|
||||
const paginatedDecors = filteredDecors.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
useEffect(() => {
|
||||
fetchDecors()
|
||||
}, [fetchDecors])
|
||||
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage)
|
||||
|
||||
// 处理添加家居装饰
|
||||
const handleAddDecor = (newDecor: HomeDecor) => {
|
||||
setDecors((prevDecors) => [...prevDecors, newDecor])
|
||||
const handleAddDecor = (newDecor: ComponentHomeDecor) => {
|
||||
fetchDecors()
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: `家居装饰 ${newDecor.name} 已成功添加`,
|
||||
@ -105,8 +99,8 @@ export default function HomeDecorPage() {
|
||||
}
|
||||
|
||||
// 处理编辑家居装饰
|
||||
const handleEditDecor = (updatedDecor: HomeDecor) => {
|
||||
setDecors((prevDecors) => prevDecors.map((decor) => (decor.id === updatedDecor.id ? updatedDecor : decor)))
|
||||
const handleEditDecor = (updatedDecor: ComponentHomeDecor) => {
|
||||
fetchDecors()
|
||||
setSelectedDecor(null)
|
||||
setIsEditDialogOpen(false)
|
||||
toast({
|
||||
@ -117,19 +111,64 @@ export default function HomeDecorPage() {
|
||||
|
||||
// 处理删除家居装饰
|
||||
const handleDeleteDecor = async (decorId: string) => {
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
try {
|
||||
await deleteHomeDecor(decorId)
|
||||
await fetchDecors()
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "家居装饰已成功删除",
|
||||
variant: "destructive",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("删除家居装饰失败:", error)
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: "无法删除家居装饰,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setDecors((prevDecors) => prevDecors.filter((decor) => decor.id !== decorId))
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "家居装饰已成功删除",
|
||||
variant: "destructive",
|
||||
})
|
||||
// 发布家居装饰
|
||||
const handlePublishDecor = async (decorId: string, decorName: string) => {
|
||||
try {
|
||||
await publishHomeDecor(decorId)
|
||||
await fetchDecors()
|
||||
toast({
|
||||
title: "发布成功",
|
||||
description: `家居装饰 "${decorName}" 已成功发布`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("发布家居装饰失败:", error)
|
||||
toast({
|
||||
title: "发布失败",
|
||||
description: "无法发布家居装饰,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 归档家居装饰
|
||||
const handleArchiveDecor = async (decorId: string, decorName: string) => {
|
||||
try {
|
||||
await archiveHomeDecor(decorId)
|
||||
await fetchDecors()
|
||||
toast({
|
||||
title: "归档成功",
|
||||
description: `家居装饰 "${decorName}" 已归档`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("归档家居装饰失败:", error)
|
||||
toast({
|
||||
title: "归档失败",
|
||||
description: "无法归档家居装饰,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEditDialog = (decor: HomeDecor) => {
|
||||
const openEditDialog = (decor: ComponentHomeDecor) => {
|
||||
setSelectedDecor(decor)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
@ -151,7 +190,7 @@ export default function HomeDecorPage() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value)
|
||||
setCurrentPage(1) // 重置到第一页
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -161,90 +200,122 @@ export default function HomeDecorPage() {
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-bold flex items-center">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
家居装饰列表
|
||||
</span>
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">家居装饰列表</span>
|
||||
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
|
||||
</CardTitle>
|
||||
<CardDescription>管理洛天依的家居装饰卡牌</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>装饰名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>稀有度</TableHead>
|
||||
<TableHead>发布日期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>激活数量</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedDecors.map((decor) => (
|
||||
<TableRow key={decor.id} className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">{decor.id}</TableCell>
|
||||
<TableCell className="font-medium text-pink-600">{decor.name}</TableCell>
|
||||
<TableCell>{decor.type}</TableCell>
|
||||
<TableCell>{decor.rarity}</TableCell>
|
||||
<TableCell>{decor.releaseDate || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
decor.status === "已发布" ? "bg-green-500 hover:bg-green-600" : "bg-gray-500 hover:bg-gray-600"
|
||||
}
|
||||
>
|
||||
{decor.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{decor.activatedCount}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
|
||||
<Link href={`/home-decor/${decor.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">查看详情</span>
|
||||
</Link>
|
||||
</Button>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
|
||||
<span className="ml-2 text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>装饰名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>稀有度</TableHead>
|
||||
<TableHead>发布日期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>激活数量</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{decors.map((decor) => (
|
||||
<TableRow key={decor.id} className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">{decor.id}</TableCell>
|
||||
<TableCell className="font-medium text-pink-600">{decor.name}</TableCell>
|
||||
<TableCell>{decor.type}</TableCell>
|
||||
<TableCell>{decor.rarity}</TableCell>
|
||||
<TableCell>{decor.releaseDate || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
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}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{decor.activatedCount}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
|
||||
<Link href={`/home-decor/${decor.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">查看详情</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{decor.status !== "已发布" && (
|
||||
<>
|
||||
{/* 草稿状态:显示发布按钮 */}
|
||||
{decor.status === "草稿" && (
|
||||
<PublishConfirmationDialog
|
||||
title="发布家居装饰"
|
||||
description="发布后该家居装饰将可被用于卡牌生成"
|
||||
itemName={decor.name}
|
||||
onPublish={() => handlePublishDecor(decor.id, decor.name)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 已发布状态:显示归档按钮 */}
|
||||
{decor.status === "已发布" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-pink-50 hover:text-pink-600"
|
||||
onClick={() => openEditDialog(decor)}
|
||||
className="hover:bg-orange-50 hover:text-orange-600"
|
||||
title="归档"
|
||||
onClick={() => handleArchiveDecor(decor.id, decor.name)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<Archive className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
title="删除家居装饰"
|
||||
description="此操作将永久删除该家居装饰及其所有相关数据。"
|
||||
itemName={decor.name}
|
||||
onDelete={() => handleDeleteDecor(decor.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(decor.status !== "已发布" || isSuperUser()) && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-pink-50 hover:text-pink-600"
|
||||
onClick={() => openEditDialog(decor)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{paginatedDecors.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
没有找到匹配的家居装饰
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<DeleteConfirmationDialog
|
||||
title="删除家居装饰"
|
||||
description="此操作将永久删除该家居装饰及其所有相关数据。"
|
||||
itemName={decor.name}
|
||||
onDelete={() => handleDeleteDecor(decor.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{decors.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
没有找到匹配的家居装饰
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
显示 {paginatedDecors.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
|
||||
{Math.min(currentPage * itemsPerPage, filteredDecors.length)} 共 {filteredDecors.length} 个家居装饰
|
||||
显示 {decors.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
|
||||
{Math.min(currentPage * itemsPerPage, totalItems)} 共 {totalItems} 个家居装饰
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
|
||||
@ -35,8 +35,8 @@ export default function LoginPage() {
|
||||
const response = await emailLogin(email, password)
|
||||
console.log(response)
|
||||
|
||||
// 保存登录凭证
|
||||
saveAuthToken(response.data.token)
|
||||
// 保存登录凭证(包含角色信息)
|
||||
saveAuthToken(response.data.token, response.data.is_superuser, response.data.role)
|
||||
|
||||
// 设置登录状态
|
||||
localStorage.setItem("isLoggedIn", "true")
|
||||
|
||||
@ -1,99 +1,61 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, use } from "react"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ArrowLeft, Edit, AlertTriangle, FileText } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ArrowLeft, Edit, AlertTriangle, FileText, Plus, Download, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { AddPrintBatchDialog } from "@/components/outfits/add-print-batch-dialog"
|
||||
import { ExportCardsDialog } from "@/components/outfits/export-cards-dialog"
|
||||
import { isSuperUser } from "@/lib/api/auth"
|
||||
import { getOutfit } from "@/lib/api/outfits"
|
||||
import type { Outfit } from "@/lib/api/types"
|
||||
|
||||
// Mock data for the outfit details
|
||||
const outfitData = {
|
||||
OFT001: {
|
||||
id: "OFT001",
|
||||
name: "经典原创服装",
|
||||
type: "常规",
|
||||
rarity: "稀有",
|
||||
description: "洛天依的经典原创服装,简约而不失时尚,适合各种场合穿着。",
|
||||
releaseDate: "2023-10-15",
|
||||
status: "已发布",
|
||||
activatedCount: 1245,
|
||||
printedCount: 2000,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{ id: "B001", date: "2023-09-01", quantity: 1000, startId: "OFT001-0001", endId: "OFT001-1000" },
|
||||
{ id: "B002", date: "2023-12-15", quantity: 1000, startId: "OFT001-1001", endId: "OFT001-2000" },
|
||||
],
|
||||
},
|
||||
OFT002: {
|
||||
id: "OFT002",
|
||||
name: "夏日泳装",
|
||||
type: "季节限定",
|
||||
rarity: "史诗",
|
||||
description: "专为夏季设计的清凉泳装,让洛天依在夏日活动中更加亮眼。",
|
||||
releaseDate: "2023-06-01",
|
||||
status: "已发布",
|
||||
activatedCount: 876,
|
||||
printedCount: 1500,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [{ id: "B003", date: "2023-05-10", quantity: 1500, startId: "OFT002-0001", endId: "OFT002-1500" }],
|
||||
},
|
||||
OFT003: {
|
||||
id: "OFT003",
|
||||
name: "冬日圣诞服",
|
||||
type: "节日限定",
|
||||
rarity: "史诗",
|
||||
description: "圣诞节特别设计的节日服装,温暖而喜庆,让洛天依陪你度过欢乐的圣诞。",
|
||||
releaseDate: "2023-12-01",
|
||||
status: "已发布",
|
||||
activatedCount: 1032,
|
||||
printedCount: 2000,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [{ id: "B004", date: "2023-11-15", quantity: 2000, startId: "OFT003-0001", endId: "OFT003-2000" }],
|
||||
},
|
||||
OFT004: {
|
||||
id: "OFT004",
|
||||
name: "校园制服",
|
||||
type: "常规",
|
||||
rarity: "稀有",
|
||||
description: "清新可爱的校园风格制服,展现洛天依青春活力的一面。",
|
||||
releaseDate: "2024-01-15",
|
||||
status: "已发布",
|
||||
activatedCount: 1567,
|
||||
printedCount: 3000,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{ id: "B005", date: "2024-01-05", quantity: 2000, startId: "OFT004-0001", endId: "OFT004-2000" },
|
||||
{ id: "B006", date: "2024-02-10", quantity: 1000, startId: "OFT004-2001", endId: "OFT004-3000" },
|
||||
],
|
||||
},
|
||||
OFT005: {
|
||||
id: "OFT005",
|
||||
name: "演唱会礼服",
|
||||
type: "特别版",
|
||||
rarity: "传说",
|
||||
description: "为重要演唱会设计的华丽礼服,尽显洛天依的优雅与魅力。",
|
||||
releaseDate: "",
|
||||
status: "未发布",
|
||||
activatedCount: 0,
|
||||
printedCount: 1000,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [{ id: "B007", date: "2024-03-20", quantity: 1000, startId: "OFT005-0001", endId: "OFT005-1000" }],
|
||||
},
|
||||
}
|
||||
export default function OutfitDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const [outfit, setOutfit] = useState<Outfit | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
export default function OutfitDetailPage({ params }: { params: { id: string } }) {
|
||||
const outfit = outfitData[params.id as keyof typeof outfitData]
|
||||
useEffect(() => {
|
||||
const fetchOutfit = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await getOutfit(id)
|
||||
setOutfit(data)
|
||||
} catch (err) {
|
||||
console.error("获取服装详情失败:", err)
|
||||
setError(`找不到ID为 ${id} 的服装`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchOutfit()
|
||||
}, [id])
|
||||
|
||||
if (!outfit) {
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
|
||||
<span className="ml-2 text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !outfit) {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex flex-col items-center justify-center h-[60vh]">
|
||||
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">服装不存在</h1>
|
||||
<p className="text-gray-500 mb-6">找不到ID为 {params.id} 的服装</p>
|
||||
<p className="text-gray-500 mb-6">{error || `找不到ID为 ${id} 的服装`}</p>
|
||||
<Button asChild>
|
||||
<Link href="/outfits">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
@ -106,10 +68,13 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
|
||||
}
|
||||
|
||||
const isPublished = outfit.status === "已发布"
|
||||
const printedCount = outfit.batchesCount || 0
|
||||
const activatedCount = outfit.activeCardsCount || 0
|
||||
const activationRate = printedCount > 0 ? Math.round((activatedCount / printedCount) * 100) : 0
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="absolute top-0 right-0 w-1/2 h-48 bg-gradient-to-bl from-pink-200 via-purple-200 to-transparent opacity-20 rounded-bl-full" />
|
||||
<div className="absolute top-0 right-0 w-1/2 h-48 bg-gradient-to-bl from-purple-200 via-pink-200 to-transparent opacity-20 rounded-bl-full" />
|
||||
|
||||
<div className="flex items-center mb-6">
|
||||
<Button variant="ghost" size="sm" className="mr-4" asChild>
|
||||
@ -120,12 +85,12 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
|
||||
</Button>
|
||||
<DashboardHeader heading={outfit.name} text={`服装ID: ${outfit.id}`}>
|
||||
<div className="flex space-x-2 ml-auto">
|
||||
{!isPublished && (
|
||||
{(!isPublished || isSuperUser()) && (
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Link href={`/outfits/edit/${params.id}`}>
|
||||
<Link href={`/outfits/edit/${id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑服装
|
||||
</Link>
|
||||
@ -136,14 +101,14 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
|
||||
</DashboardHeader>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="details" className="space-y-4">
|
||||
<TabsList>
|
||||
<Tabs defaultValue="details" className="w-full">
|
||||
<TabsList className="grid w-full md:w-auto grid-cols-2 md:grid-cols-3 mb-4">
|
||||
<TabsTrigger value="details">服装详情</TabsTrigger>
|
||||
<TabsTrigger value="batches">批次管理</TabsTrigger>
|
||||
<TabsTrigger value="analytics">数据分析</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<TabsContent value="details" className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1 border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
@ -151,9 +116,18 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<div className="relative w-full aspect-square max-w-[300px] rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
|
||||
<img src={outfit.image || "/placeholder.svg"} alt={outfit.name} className="object-cover" />
|
||||
{outfit.imageUrl && !outfit.imageUrl.includes("placeholder") ? (
|
||||
<img src={outfit.imageUrl} alt={outfit.name} className="object-cover w-full h-full" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-gray-400">
|
||||
<svg className="h-16 w-16 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
||||
</svg>
|
||||
<span className="text-sm">暂无图片</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3">
|
||||
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{outfit.status}</Badge>
|
||||
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{outfit.status || "未发布"}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -167,38 +141,43 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">服装类型</p>
|
||||
<p className="font-medium">{outfit.type}</p>
|
||||
<p className="font-medium">{outfit.category || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">稀有度</p>
|
||||
<p className="font-medium">{outfit.rarity}</p>
|
||||
<p className="font-medium">{outfit.rarity || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">发布日期</p>
|
||||
<p className="font-medium">{outfit.releaseDate || "尚未发布"}</p>
|
||||
<p className="font-medium">{outfit.publishedAt || "尚未发布"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">激活数量</p>
|
||||
<p className="font-medium">{outfit.activatedCount}</p>
|
||||
<p className="font-medium">{activatedCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">印刷总数</p>
|
||||
<p className="font-medium">{outfit.printedCount}</p>
|
||||
<p className="text-sm font-medium text-gray-500">创建日期</p>
|
||||
<p className="font-medium">{outfit.createdAt || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">剩余库存</p>
|
||||
<p className="font-medium">{outfit.printedCount - outfit.activatedCount}</p>
|
||||
</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>
|
||||
<p className="text-sm font-medium text-gray-500">激活率</p>
|
||||
<p className="font-medium">{activationRate}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">服装描述</p>
|
||||
<p className="text-gray-700">{outfit.description || "暂无描述"}</p>
|
||||
</div>
|
||||
|
||||
{isPublished && (
|
||||
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-700">该服装已发布,基本属性不可修改。您仍可以增加印刷数量。</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
{isSuperUser()
|
||||
? "该服装已发布,您以超级管理员身份仍可编辑和删除。请谨慎操作。"
|
||||
: "该服装已发布,基本属性不可修改。您仍可以增加印刷数量。"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@ -206,11 +185,11 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batches" className="space-y-4">
|
||||
<TabsContent value="batches" className="space-y-6">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold">印刷批次</CardTitle>
|
||||
<CardTitle className="text-lg font-bold">印刷批次管理</CardTitle>
|
||||
<CardDescription>管理服装卡牌的印刷批次和卡牌ID</CardDescription>
|
||||
</div>
|
||||
<AddPrintBatchDialog outfitId={outfit.id} isPublished={isPublished} />
|
||||
@ -223,55 +202,79 @@ export default function OutfitDetailPage({ params }: { params: { id: string } })
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">批次ID</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">创建日期</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">数量</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">起始ID</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">结束ID</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">状态</th>
|
||||
<th className="py-3 px-4 text-right text-sm font-medium text-gray-500">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{outfit.batches.map((batch) => (
|
||||
<tr key={batch.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 text-sm font-medium">{batch.id}</td>
|
||||
<td className="py-3 px-4 text-sm">{batch.date}</td>
|
||||
<td className="py-3 px-4 text-sm">{batch.quantity}</td>
|
||||
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.startId}</td>
|
||||
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.endId}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<Button variant="ghost" size="sm" className="h-8 hover:bg-blue-50 hover:text-blue-600">
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
导出ID
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||
批次数据将从后端加载
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics" className="space-y-4">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-green-50">
|
||||
<Card className="border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">数据分析</CardTitle>
|
||||
<CardDescription>查看服装卡牌的激活数据和使用情况</CardDescription>
|
||||
<CardTitle className="text-lg font-bold">批次操作</CardTitle>
|
||||
<CardDescription>批量管理卡牌批次</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<p className="text-gray-500">激活数据图表</p>
|
||||
</div>
|
||||
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<p className="text-gray-500">地区分布图表</p>
|
||||
</div>
|
||||
<div className="md:col-span-2 h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<p className="text-gray-500">时间趋势图表</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button variant="outline" className="border-blue-200 hover:bg-blue-50 hover:text-blue-700">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出所有批次
|
||||
</Button>
|
||||
<Button variant="outline" className="border-purple-200 hover:bg-purple-50 hover:text-purple-700">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
批量添加批次
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics" className="space-y-6">
|
||||
<Card className="border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">激活数据分析</CardTitle>
|
||||
<CardDescription>服装卡牌激活情况统计</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">激活数据图表将在此显示</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-green-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">地区分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px] flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">地区分布图表将在此显示</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">时间趋势</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px] flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">时间趋势图表将在此显示</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
|
||||
@ -1,166 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Edit, Eye, Sparkles, Plus } from "lucide-react"
|
||||
import { Search, Edit, Eye, Loader2, Archive } from "lucide-react"
|
||||
import { AddOutfitDialog } from "@/components/outfits/add-outfit-dialog"
|
||||
import type { DisplayOutfit } from "@/components/outfits/add-outfit-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { isSuperUser } from "@/lib/api/auth"
|
||||
import { getOutfits, deleteOutfit, publishOutfit, archiveOutfit } from "@/lib/api/outfits"
|
||||
import type { Outfit } from "@/lib/api/types"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return ""
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function toDisplayOutfit(outfit: Outfit): DisplayOutfit {
|
||||
return {
|
||||
id: outfit.id,
|
||||
name: outfit.name,
|
||||
type: outfit.category || "",
|
||||
rarity: outfit.rarity || "",
|
||||
description: outfit.description || "",
|
||||
releaseDate: formatDate(outfit.publishedAt || outfit.createdAt || ""),
|
||||
status: outfit.status || "未发布",
|
||||
activatedCount: outfit.activeCardsCount || 0,
|
||||
image: outfit.imageUrl || "",
|
||||
}
|
||||
}
|
||||
|
||||
export default function OutfitsPage() {
|
||||
// 直接在页面中实现对话框
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(1)
|
||||
const [outfitType, setOutfitType] = useState("")
|
||||
const [rarity, setRarity] = useState("")
|
||||
const [printQuantity, setPrintQuantity] = useState(1000)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const [outfits, setOutfits] = useState<DisplayOutfit[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [selectedOutfit, setSelectedOutfit] = useState<DisplayOutfit | null>(null)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
setIsSubmitting(false)
|
||||
setOpen(false)
|
||||
// 重置表单
|
||||
setStep(1)
|
||||
const itemsPerPage = 10
|
||||
|
||||
const fetchOutfits = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getOutfits({
|
||||
page: currentPage,
|
||||
pageSize: itemsPerPage,
|
||||
search: searchTerm || undefined,
|
||||
})
|
||||
setOutfits(response.items.map(toDisplayOutfit))
|
||||
setTotalItems(response.total)
|
||||
} catch (error) {
|
||||
console.error("获取服装列表失败:", error)
|
||||
toast({
|
||||
title: "获取失败",
|
||||
description: "无法获取服装列表,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [currentPage, searchTerm, toast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchOutfits()
|
||||
}, [fetchOutfits])
|
||||
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage)
|
||||
|
||||
const handleAddOutfit = (newOutfit: DisplayOutfit) => {
|
||||
fetchOutfits()
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: `服装 ${newOutfit.name} 已成功添加`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
setStep(step + 1)
|
||||
const handleEditOutfit = (updatedOutfit: DisplayOutfit) => {
|
||||
fetchOutfits()
|
||||
setSelectedOutfit(null)
|
||||
setIsEditDialogOpen(false)
|
||||
toast({
|
||||
title: "更新成功",
|
||||
description: `服装 ${updatedOutfit.name} 已成功更新`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
setStep(step - 1)
|
||||
const handleDeleteOutfit = async (outfitId: string) => {
|
||||
try {
|
||||
await deleteOutfit(outfitId)
|
||||
await fetchOutfits()
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "服装已成功删除",
|
||||
variant: "destructive",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("删除服装失败:", error)
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: "无法删除服装,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublishOutfit = async (outfitId: string, outfitName: string) => {
|
||||
try {
|
||||
await publishOutfit(outfitId)
|
||||
await fetchOutfits()
|
||||
toast({
|
||||
title: "发布成功",
|
||||
description: `服装 "${outfitName}" 已成功发布`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("发布服装失败:", error)
|
||||
toast({
|
||||
title: "发布失败",
|
||||
description: "无法发布服装,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchiveOutfit = async (outfitId: string, outfitName: string) => {
|
||||
try {
|
||||
await archiveOutfit(outfitId)
|
||||
await fetchOutfits()
|
||||
toast({
|
||||
title: "归档成功",
|
||||
description: `服装 "${outfitName}" 已归档`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("归档服装失败:", error)
|
||||
toast({
|
||||
title: "归档失败",
|
||||
description: "无法归档服装,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (outfit: DisplayOutfit) => {
|
||||
setSelectedOutfit(outfit)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="服装管理" text="管理洛天依的服装卡牌">
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加服装
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
添加新服装
|
||||
</DialogTitle>
|
||||
<DialogDescription>填写服装信息以创建新的服装卡牌。创建后将生成唯一的卡牌ID。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
服装名称 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="输入服装名称"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-right">
|
||||
服装类型 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={outfitType} onValueChange={setOutfitType} required>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue placeholder="选择服装类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="regular">常规</SelectItem>
|
||||
<SelectItem value="seasonal">季节限定</SelectItem>
|
||||
<SelectItem value="festival">节日限定</SelectItem>
|
||||
<SelectItem value="special">特别版</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rarity" className="text-right">
|
||||
稀有度 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={rarity} onValueChange={setRarity} required>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue placeholder="选择稀有度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="common">普通</SelectItem>
|
||||
<SelectItem value="rare">稀有</SelectItem>
|
||||
<SelectItem value="epic">史诗</SelectItem>
|
||||
<SelectItem value="legendary">传说</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="print-quantity" className="text-right">
|
||||
印刷数量 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="print-quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
value={printQuantity}
|
||||
onChange={(e) => setPrintQuantity(Number.parseInt(e.target.value))}
|
||||
placeholder="输入印刷数量"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
服装描述 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="输入服装描述"
|
||||
className="min-h-[100px] border-gray-300 focus-visible:ring-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex items-center justify-between mt-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "创建中..." : "创建服装"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AddOutfitDialog onSave={handleAddOutfit} />
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="flex items-center justify-between space-y-2 mb-6">
|
||||
@ -171,318 +178,166 @@ export default function OutfitsPage() {
|
||||
type="search"
|
||||
placeholder="搜索服装..."
|
||||
className="w-[300px] pl-8 border-none bg-white shadow-md focus-visible:ring-pink-500"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="all" className="space-y-4">
|
||||
<TabsList className="bg-white p-1 shadow-md rounded-lg border">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white rounded-md transition-all duration-300"
|
||||
>
|
||||
全部服装
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="published"
|
||||
className="data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white rounded-md transition-all duration-300"
|
||||
>
|
||||
已发布
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="unpublished"
|
||||
className="data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white rounded-md transition-all duration-300"
|
||||
>
|
||||
未发布
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="limited"
|
||||
className="data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white rounded-md transition-all duration-300"
|
||||
>
|
||||
限定服装
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-bold flex items-center">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
服装列表
|
||||
</span>
|
||||
<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>印刷数量</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>
|
||||
<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>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
|
||||
<span className="ml-2 text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>服装名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>稀有度</TableHead>
|
||||
<TableHead>发布日期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>激活数量</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{outfits.map((outfit) => (
|
||||
<TableRow key={outfit.id} className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">{outfit.id}</TableCell>
|
||||
<TableCell className="font-medium text-pink-600">{outfit.name}</TableCell>
|
||||
<TableCell>{outfit.type}</TableCell>
|
||||
<TableCell>{outfit.rarity}</TableCell>
|
||||
<TableCell>{outfit.releaseDate || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-500 hover:bg-green-600">已发布</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">1,245</TableCell>
|
||||
<TableCell className="font-medium">2,000</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
|
||||
<Link href="/outfits/OFT001">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">OFT002</TableCell>
|
||||
<TableCell className="font-medium text-blue-600">夏日泳装</TableCell>
|
||||
<TableCell>季节限定</TableCell>
|
||||
<TableCell>2023-06-01</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-500 hover:bg-green-600">已发布</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">876</TableCell>
|
||||
<TableCell className="font-medium">1,500</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
|
||||
<Link href="/outfits/OFT002">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">OFT003</TableCell>
|
||||
<TableCell className="font-medium text-red-600">冬日圣诞服</TableCell>
|
||||
<TableCell>节日限定</TableCell>
|
||||
<TableCell>2023-12-01</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-500 hover:bg-green-600">已发布</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">1,032</TableCell>
|
||||
<TableCell className="font-medium">2,000</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
|
||||
<Link href="/outfits/OFT003">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">OFT004</TableCell>
|
||||
<TableCell className="font-medium text-purple-600">校园制服</TableCell>
|
||||
<TableCell>常规</TableCell>
|
||||
<TableCell>2024-01-15</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-500 hover:bg-green-600">已发布</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">1,567</TableCell>
|
||||
<TableCell className="font-medium">3,000</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
|
||||
<Link href="/outfits/OFT004">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">OFT005</TableCell>
|
||||
<TableCell className="font-medium text-teal-600">演唱会礼服</TableCell>
|
||||
<TableCell>特别版</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-gray-500 border-gray-300">
|
||||
未发布
|
||||
<Badge
|
||||
className={
|
||||
outfit.status === "已发布"
|
||||
? "bg-green-500 hover:bg-green-600"
|
||||
: outfit.status === "已归档"
|
||||
? "bg-orange-500 hover:bg-orange-600"
|
||||
: "bg-gray-500 hover:bg-gray-600"
|
||||
}
|
||||
>
|
||||
{outfit.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">0</TableCell>
|
||||
<TableCell className="font-medium">1,000</TableCell>
|
||||
<TableCell className="font-medium">{outfit.activatedCount}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
|
||||
<Link href="/outfits/OFT005">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
|
||||
<Link href="/outfits/edit/OFT005">
|
||||
<Edit className="h-4 w-4" />
|
||||
<Link href={`/outfits/${outfit.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">查看详情</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{outfit.status === "草稿" && (
|
||||
<PublishConfirmationDialog
|
||||
title="发布服装"
|
||||
description="发布后该服装将可被用于卡牌生成"
|
||||
itemName={outfit.name}
|
||||
onPublish={() => handlePublishOutfit(outfit.id, outfit.name)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{outfit.status === "已发布" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-orange-50 hover:text-orange-600"
|
||||
title="归档"
|
||||
onClick={() => handleArchiveOutfit(outfit.id, outfit.name)}
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(outfit.status !== "已发布" || isSuperUser()) && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-pink-50 hover:text-pink-600"
|
||||
onClick={() => openEditDialog(outfit)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
title="删除服装"
|
||||
description="此操作将永久删除该服装及其所有相关数据。"
|
||||
itemName={outfit.name}
|
||||
onDelete={() => handleDeleteOutfit(outfit.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<div className="text-sm text-muted-foreground">显示 1-5 共 24 个服装</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
<TabsContent value="published" className="space-y-4">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-bold flex items-center">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
已发布服装
|
||||
</span>
|
||||
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
|
||||
</CardTitle>
|
||||
<CardDescription>已正式发布的服装,不可修改基本属性</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
{outfits.length === 0 && (
|
||||
<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 colSpan={8} className="h-24 text-center">
|
||||
没有找到匹配的服装
|
||||
</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>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
显示 {outfits.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
|
||||
{Math.min(currentPage * itemsPerPage, totalItems)} 共 {totalItems} 个服装
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<TabsContent value="unpublished" className="space-y-4">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-bold flex items-center">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
未发布服装
|
||||
</span>
|
||||
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
|
||||
</CardTitle>
|
||||
<CardDescription>尚未正式发布的服装,可以修改基本属性</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>服装名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>印刷数量</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">OFT005</TableCell>
|
||||
<TableCell className="font-medium text-teal-600">演唱会礼服</TableCell>
|
||||
<TableCell>特别版</TableCell>
|
||||
<TableCell className="font-medium">1,000</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
|
||||
<Link href="/outfits/OFT005">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
|
||||
<Link href="/outfits/edit/OFT005">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="limited" className="space-y-4">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-bold flex items-center">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
限定服装
|
||||
</span>
|
||||
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
|
||||
</CardTitle>
|
||||
<CardDescription>限时或特殊活动的限定服装</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Sparkles className="h-12 w-12 text-blue-500 mb-4" />
|
||||
<p className="text-lg font-medium text-gray-700">限定服装列表将在这里显示</p>
|
||||
<p className="text-sm text-gray-500 mt-2">您可以查看所有限时或特殊活动的限定服装</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{selectedOutfit && isEditDialogOpen && (
|
||||
<AddOutfitDialog
|
||||
mode="edit"
|
||||
initialOutfit={selectedOutfit}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
onSave={handleEditOutfit}
|
||||
/>
|
||||
)}
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@ -130,6 +130,9 @@ const initialRoles: Role[] = [
|
||||
"homeDecor.view": true,
|
||||
"homeDecor.create": true,
|
||||
"homeDecor.edit": true,
|
||||
"food.view": true,
|
||||
"food.create": true,
|
||||
"food.edit": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -141,16 +144,6 @@ const initialRoles: Role[] = [
|
||||
status: "自定义角色",
|
||||
permissions: {
|
||||
"dashboard.view": true,
|
||||
"users.view": true,
|
||||
"roles.view": true,
|
||||
"ai.view": true,
|
||||
"outfits.view": true,
|
||||
"props.view": true,
|
||||
"homeDecor.view": true,
|
||||
"food.view": true,
|
||||
"songs.view": true,
|
||||
"settings.view": true,
|
||||
"affinity.view": true,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@ -1,152 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, use } from "react"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ArrowLeft, Edit, AlertTriangle, FileText, Plus, Download } from "lucide-react"
|
||||
import { ArrowLeft, Edit, AlertTriangle, FileText, Plus, Download, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { AddPrintBatchDialog } from "@/components/props/add-print-batch-dialog"
|
||||
import { ExportCardsDialog } from "@/components/props/export-cards-dialog"
|
||||
import { isSuperUser } from "@/lib/api/auth"
|
||||
import { getProp } from "@/lib/api/props"
|
||||
import type { Prop } from "@/lib/api/types"
|
||||
|
||||
// Mock data for the prop details
|
||||
const propData = {
|
||||
PRP001: {
|
||||
id: "PRP001",
|
||||
name: "魔法麦克风",
|
||||
type: "演出道具",
|
||||
rarity: "稀有",
|
||||
description: "洛天依的经典原创道具,可以增强歌声的魔力,让听众更加沉浸在音乐中。",
|
||||
releaseDate: "2023-11-15",
|
||||
status: "已发布",
|
||||
activatedCount: 1245,
|
||||
printedCount: 2000,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{
|
||||
id: "B001",
|
||||
date: "2023-09-01",
|
||||
quantity: 1000,
|
||||
startId: "PRP001-0001",
|
||||
endId: "PRP001-1000",
|
||||
status: "已激活",
|
||||
activatedCount: 980,
|
||||
},
|
||||
{
|
||||
id: "B002",
|
||||
date: "2023-12-15",
|
||||
quantity: 1000,
|
||||
startId: "PRP001-1001",
|
||||
endId: "PRP001-2000",
|
||||
status: "已激活",
|
||||
activatedCount: 265,
|
||||
},
|
||||
],
|
||||
},
|
||||
PRP002: {
|
||||
id: "PRP002",
|
||||
name: "星光魔杖",
|
||||
type: "互动道具",
|
||||
rarity: "史诗",
|
||||
description: "挥舞魔杖可以创造出美丽的星光效果,增加互动时的好感度。",
|
||||
releaseDate: "2023-12-01",
|
||||
status: "已发布",
|
||||
activatedCount: 876,
|
||||
printedCount: 1500,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{
|
||||
id: "B003",
|
||||
date: "2023-05-10",
|
||||
quantity: 1500,
|
||||
startId: "PRP002-0001",
|
||||
endId: "PRP002-1500",
|
||||
status: "已激活",
|
||||
activatedCount: 876,
|
||||
},
|
||||
],
|
||||
},
|
||||
PRP003: {
|
||||
id: "PRP003",
|
||||
name: "音乐盒",
|
||||
type: "收藏品",
|
||||
rarity: "传说",
|
||||
description: "精美的音乐盒,打开后会播放洛天依的经典歌曲,是珍贵的收藏品。",
|
||||
releaseDate: "2024-01-10",
|
||||
status: "已发布",
|
||||
activatedCount: 532,
|
||||
printedCount: 1000,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{
|
||||
id: "B004",
|
||||
date: "2023-11-15",
|
||||
quantity: 1000,
|
||||
startId: "PRP003-0001",
|
||||
endId: "PRP003-1000",
|
||||
status: "已激活",
|
||||
activatedCount: 532,
|
||||
},
|
||||
],
|
||||
},
|
||||
PRP004: {
|
||||
id: "PRP004",
|
||||
name: "虚拟相机",
|
||||
type: "互动道具",
|
||||
rarity: "稀有",
|
||||
description: "可以捕捉洛天依的精彩瞬间,保存为虚拟照片。",
|
||||
releaseDate: "2024-02-05",
|
||||
status: "已发布",
|
||||
activatedCount: 967,
|
||||
printedCount: 2000,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{
|
||||
id: "B005",
|
||||
date: "2024-01-05",
|
||||
quantity: 2000,
|
||||
startId: "PRP004-0001",
|
||||
endId: "PRP004-2000",
|
||||
status: "已激活",
|
||||
activatedCount: 967,
|
||||
},
|
||||
],
|
||||
},
|
||||
PRP005: {
|
||||
id: "PRP005",
|
||||
name: "节日礼盒",
|
||||
type: "限定道具",
|
||||
rarity: "史诗",
|
||||
description: "节日限定礼盒,内含多种惊喜道具和装饰品。",
|
||||
releaseDate: "",
|
||||
status: "未发布",
|
||||
activatedCount: 0,
|
||||
printedCount: 1000,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
batches: [
|
||||
{
|
||||
id: "B007",
|
||||
date: "2024-03-20",
|
||||
quantity: 1000,
|
||||
startId: "PRP005-0001",
|
||||
endId: "PRP005-1000",
|
||||
status: "未激活",
|
||||
activatedCount: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
export default function PropDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const [prop, setProp] = useState<Prop | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
export default function PropDetailPage({ params }: { params: { id: string } }) {
|
||||
const prop = propData[params.id as keyof typeof propData]
|
||||
useEffect(() => {
|
||||
const fetchProp = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await getProp(id)
|
||||
setProp(data)
|
||||
} catch (err) {
|
||||
console.error("获取道具详情失败:", err)
|
||||
setError(`找不到ID为 ${id} 的道具`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchProp()
|
||||
}, [id])
|
||||
|
||||
if (!prop) {
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
|
||||
<span className="ml-2 text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !prop) {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex flex-col items-center justify-center h-[60vh]">
|
||||
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">道具不存在</h1>
|
||||
<p className="text-gray-500 mb-6">找不到ID为 {params.id} 的道具</p>
|
||||
<p className="text-gray-500 mb-6">{error || `找不到ID为 ${id} 的道具`}</p>
|
||||
<Button asChild>
|
||||
<Link href="/props">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
@ -159,7 +68,9 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
|
||||
}
|
||||
|
||||
const isPublished = prop.status === "已发布"
|
||||
const activationRate = prop.printedCount > 0 ? Math.round((prop.activatedCount / prop.printedCount) * 100) : 0
|
||||
const printedCount = prop.batchesCount || 0
|
||||
const activatedCount = prop.activeCardsCount || 0
|
||||
const activationRate = printedCount > 0 ? Math.round((activatedCount / printedCount) * 100) : 0
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
@ -174,12 +85,12 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
|
||||
</Button>
|
||||
<DashboardHeader heading={prop.name} text={`道具ID: ${prop.id}`}>
|
||||
<div className="flex space-x-2 ml-auto">
|
||||
{!isPublished && (
|
||||
{(!isPublished || isSuperUser()) && (
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Link href={`/props/edit/${params.id}`}>
|
||||
<Link href={`/props/edit/${id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑道具
|
||||
</Link>
|
||||
@ -205,9 +116,18 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<div className="relative w-full aspect-square max-w-[300px] rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
|
||||
<img src={prop.image || "/placeholder.svg"} alt={prop.name} className="object-cover" />
|
||||
{prop.imageUrl && !prop.imageUrl.includes("placeholder") ? (
|
||||
<img src={prop.imageUrl} alt={prop.name} className="object-cover w-full h-full" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-gray-400">
|
||||
<svg className="h-16 w-16 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
||||
</svg>
|
||||
<span className="text-sm">暂无图片</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3">
|
||||
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{prop.status}</Badge>
|
||||
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{prop.status || "未发布"}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -221,23 +141,23 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">道具类型</p>
|
||||
<p className="font-medium">{prop.type}</p>
|
||||
<p className="font-medium">{prop.category || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">稀有度</p>
|
||||
<p className="font-medium">{prop.rarity}</p>
|
||||
<p className="font-medium">{prop.rarity || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">发布日期</p>
|
||||
<p className="font-medium">{prop.releaseDate || "尚未发布"}</p>
|
||||
<p className="font-medium">{prop.publishedAt || "尚未发布"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">激活数量</p>
|
||||
<p className="font-medium">{prop.activatedCount}</p>
|
||||
<p className="font-medium">{activatedCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">印刷总数</p>
|
||||
<p className="font-medium">{prop.printedCount}</p>
|
||||
<p className="text-sm font-medium text-gray-500">创建日期</p>
|
||||
<p className="font-medium">{prop.createdAt || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">激活率</p>
|
||||
@ -247,13 +167,17 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">道具描述</p>
|
||||
<p className="text-gray-700">{prop.description}</p>
|
||||
<p className="text-gray-700">{prop.description || "暂无描述"}</p>
|
||||
</div>
|
||||
|
||||
{isPublished && (
|
||||
<div className="mt-6 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-700">该道具已发布,基本属性不可修改。您仍可以增加印刷数量。</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
{isSuperUser()
|
||||
? "该道具已发布,您以超级管理员身份仍可编辑和删除。请谨慎操作。"
|
||||
: "该道具已发布,基本属性不可修改。您仍可以增加印刷数量。"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@ -278,41 +202,16 @@ export default function PropDetailPage({ params }: { params: { id: string } }) {
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">批次ID</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">创建日期</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">数量</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">起始ID</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">结束ID</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">状态</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">激活数</th>
|
||||
<th className="py-3 px-4 text-right text-sm font-medium text-gray-500">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{prop.batches.map((batch) => (
|
||||
<tr key={batch.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 text-sm font-medium">{batch.id}</td>
|
||||
<td className="py-3 px-4 text-sm">{batch.date}</td>
|
||||
<td className="py-3 px-4 text-sm">{batch.quantity}</td>
|
||||
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.startId}</td>
|
||||
<td className="py-3 px-4 text-sm font-mono text-xs">{batch.endId}</td>
|
||||
<td className="py-3 px-4 text-sm">
|
||||
<Badge className={`${batch.status === "已激活" ? "bg-green-500" : "bg-gray-500"}`}>
|
||||
{batch.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm">{batch.activatedCount}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="ghost" size="sm" className="h-8 hover:bg-blue-50 hover:text-blue-600">
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
查看
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 hover:bg-green-50 hover:text-green-600">
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||
批次数据将从后端加载
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@ -8,96 +8,91 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Edit, Eye } from "lucide-react"
|
||||
import { Search, Edit, Eye, Loader2, Archive } from "lucide-react"
|
||||
import { AddPropDialog } from "@/components/props/add-prop-dialog"
|
||||
import type { Prop } from "@/components/props/prop-detail-dialog"
|
||||
import type { Prop as ComponentProp } from "@/components/props/prop-detail-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { isSuperUser } from "@/lib/api/auth"
|
||||
import { getProps, deleteProp, publishProp, archiveProp } from "@/lib/api/props"
|
||||
import type { Prop } from "@/lib/api/types"
|
||||
import Link from "next/link"
|
||||
|
||||
// 初始道具数据
|
||||
const initialProps: Prop[] = [
|
||||
{
|
||||
id: "PRP001",
|
||||
name: "魔法麦克风",
|
||||
type: "演出道具",
|
||||
rarity: "稀有",
|
||||
description: "洛天依的经典原创道具,可以增强歌声的魔力,让听众更加沉浸在音乐中。",
|
||||
releaseDate: "2023-11-15",
|
||||
status: "已发布",
|
||||
activatedCount: 1245,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "PRP002",
|
||||
name: "星光魔杖",
|
||||
type: "互动道具",
|
||||
rarity: "史诗",
|
||||
description: "挥舞魔杖可以创造出美丽的星光效果,增加互动时的好感度。",
|
||||
releaseDate: "2023-12-01",
|
||||
status: "已发布",
|
||||
activatedCount: 876,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "PRP003",
|
||||
name: "音乐盒",
|
||||
type: "收藏品",
|
||||
rarity: "传说",
|
||||
description: "精美的音乐盒,打开后会播放洛天依的经典歌曲,是珍贵的收藏品。",
|
||||
releaseDate: "2024-01-10",
|
||||
status: "已发布",
|
||||
activatedCount: 532,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "PRP004",
|
||||
name: "虚拟相机",
|
||||
type: "互动道具",
|
||||
rarity: "稀有",
|
||||
description: "可以捕捉洛天依的精彩瞬间,保存为虚拟照片。",
|
||||
releaseDate: "2024-02-05",
|
||||
status: "已发布",
|
||||
activatedCount: 967,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "PRP005",
|
||||
name: "节日礼盒",
|
||||
type: "限定道具",
|
||||
rarity: "史诗",
|
||||
description: "节日限定礼盒,内含多种惊喜道具和装饰品。",
|
||||
releaseDate: "",
|
||||
status: "未发布",
|
||||
activatedCount: 0,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
]
|
||||
// 格式化日期时间
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return ""
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// 将 API Prop 转换为组件显示用的 Prop
|
||||
function toDisplayProp(prop: Prop): ComponentProp {
|
||||
return {
|
||||
id: prop.id,
|
||||
name: prop.name,
|
||||
type: prop.category || "",
|
||||
rarity: prop.rarity || "",
|
||||
description: prop.description || "",
|
||||
releaseDate: formatDate(prop.publishedAt || prop.createdAt || ""),
|
||||
status: prop.status || "未发布",
|
||||
activatedCount: prop.activeCardsCount || 0,
|
||||
image: prop.imageUrl || "/placeholder.svg?height=300&width=300",
|
||||
}
|
||||
}
|
||||
|
||||
export default function PropsPage() {
|
||||
const { toast } = useToast()
|
||||
const [props, setProps] = useState<Prop[]>(initialProps)
|
||||
const [props, setProps] = useState<ComponentProp[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [selectedProp, setSelectedProp] = useState<Prop | null>(null)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [selectedProp, setSelectedProp] = useState<ComponentProp | null>(null)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const itemsPerPage = 5
|
||||
const itemsPerPage = 10
|
||||
|
||||
// 过滤和分页
|
||||
const filteredProps = props.filter(
|
||||
(prop) =>
|
||||
prop.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
prop.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
prop.type.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
// 从后端获取道具列表
|
||||
const fetchProps = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getProps({
|
||||
page: currentPage,
|
||||
pageSize: itemsPerPage,
|
||||
search: searchTerm || undefined,
|
||||
})
|
||||
setProps(response.items.map(toDisplayProp))
|
||||
setTotalItems(response.total)
|
||||
} catch (error) {
|
||||
console.error("获取道具列表失败:", error)
|
||||
toast({
|
||||
title: "获取失败",
|
||||
description: "无法获取道具列表,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [currentPage, searchTerm, toast])
|
||||
|
||||
const totalPages = Math.ceil(filteredProps.length / itemsPerPage)
|
||||
const paginatedProps = filteredProps.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
useEffect(() => {
|
||||
fetchProps()
|
||||
}, [fetchProps])
|
||||
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage)
|
||||
|
||||
// 处理添加道具
|
||||
const handleAddProp = (newProp: Prop) => {
|
||||
setProps((prevProps) => [...prevProps, newProp])
|
||||
const handleAddProp = (newProp: ComponentProp) => {
|
||||
// 添加后刷新列表
|
||||
fetchProps()
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: `道具 ${newProp.name} 已成功添加`,
|
||||
@ -105,8 +100,9 @@ export default function PropsPage() {
|
||||
}
|
||||
|
||||
// 处理编辑道具
|
||||
const handleEditProp = (updatedProp: Prop) => {
|
||||
setProps((prevProps) => prevProps.map((prop) => (prop.id === updatedProp.id ? updatedProp : prop)))
|
||||
const handleEditProp = (updatedProp: ComponentProp) => {
|
||||
// 编辑后刷新列表
|
||||
fetchProps()
|
||||
setSelectedProp(null)
|
||||
setIsEditDialogOpen(false)
|
||||
toast({
|
||||
@ -115,21 +111,67 @@ export default function PropsPage() {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理删除道具
|
||||
// 处理删除道具 - 调用真实的后端 API
|
||||
const handleDeleteProp = async (propId: string) => {
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
try {
|
||||
await deleteProp(propId)
|
||||
// 删除后刷新列表
|
||||
await fetchProps()
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "道具已成功删除",
|
||||
variant: "destructive",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("删除道具失败:", error)
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: "无法删除道具,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setProps((prevProps) => prevProps.filter((prop) => prop.id !== propId))
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "道具已成功删除",
|
||||
variant: "destructive",
|
||||
})
|
||||
// 发布道具
|
||||
const handlePublishProp = async (propId: string, propName: string) => {
|
||||
try {
|
||||
await publishProp(propId)
|
||||
await fetchProps()
|
||||
toast({
|
||||
title: "发布成功",
|
||||
description: `道具 "${propName}" 已成功发布`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("发布道具失败:", error)
|
||||
toast({
|
||||
title: "发布失败",
|
||||
description: "无法发布道具,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 归档道具
|
||||
const handleArchiveProp = async (propId: string, propName: string) => {
|
||||
try {
|
||||
await archiveProp(propId)
|
||||
await fetchProps()
|
||||
toast({
|
||||
title: "归档成功",
|
||||
description: `道具 "${propName}" 已归档`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("归档道具失败:", error)
|
||||
toast({
|
||||
title: "归档失败",
|
||||
description: "无法归档道具,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEditDialog = (prop: Prop) => {
|
||||
const openEditDialog = (prop: ComponentProp) => {
|
||||
setSelectedProp(prop)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
@ -151,7 +193,7 @@ export default function PropsPage() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value)
|
||||
setCurrentPage(1) // 重置到第一页
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -167,83 +209,116 @@ export default function PropsPage() {
|
||||
<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>激活数量</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedProps.map((prop) => (
|
||||
<TableRow key={prop.id} className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">{prop.id}</TableCell>
|
||||
<TableCell className="font-medium text-pink-600">{prop.name}</TableCell>
|
||||
<TableCell>{prop.type}</TableCell>
|
||||
<TableCell>{prop.rarity}</TableCell>
|
||||
<TableCell>{prop.releaseDate || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
prop.status === "已发布" ? "bg-green-500 hover:bg-green-600" : "bg-gray-500 hover:bg-gray-600"
|
||||
}
|
||||
>
|
||||
{prop.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{prop.activatedCount}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{/* 将详情对话框替换为链接按钮 */}
|
||||
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
|
||||
<Link href={`/props/${prop.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">查看详情</span>
|
||||
</Link>
|
||||
</Button>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
|
||||
<span className="ml-2 text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>道具名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>稀有度</TableHead>
|
||||
<TableHead>发布日期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>激活数量</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.map((prop) => (
|
||||
<TableRow key={prop.id} className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">{prop.id}</TableCell>
|
||||
<TableCell className="font-medium text-pink-600">{prop.name}</TableCell>
|
||||
<TableCell>{prop.type}</TableCell>
|
||||
<TableCell>{prop.rarity}</TableCell>
|
||||
<TableCell>{prop.releaseDate || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
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}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{prop.activatedCount}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
|
||||
<Link href={`/props/${prop.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">查看详情</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{prop.status !== "已发布" && (
|
||||
<>
|
||||
{/* 草稿状态:显示发布按钮 */}
|
||||
{prop.status === "草稿" && (
|
||||
<PublishConfirmationDialog
|
||||
title="发布道具"
|
||||
description="发布后该道具将可被用于卡牌生成"
|
||||
itemName={prop.name}
|
||||
onPublish={() => handlePublishProp(prop.id, prop.name)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 已发布状态:显示归档按钮 */}
|
||||
{prop.status === "已发布" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-pink-50 hover:text-pink-600"
|
||||
onClick={() => openEditDialog(prop)}
|
||||
className="hover:bg-orange-50 hover:text-orange-600"
|
||||
title="归档"
|
||||
onClick={() => handleArchiveProp(prop.id, prop.name)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<Archive className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
title="删除道具"
|
||||
description="此操作将永久删除该道具及其所有相关数据。"
|
||||
itemName={prop.name}
|
||||
onDelete={() => handleDeleteProp(prop.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(prop.status !== "已发布" || isSuperUser()) && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-pink-50 hover:text-pink-600"
|
||||
onClick={() => openEditDialog(prop)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{paginatedProps.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
没有找到匹配的道具
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<DeleteConfirmationDialog
|
||||
title="删除道具"
|
||||
description="此操作将永久删除该道具及其所有相关数据。"
|
||||
itemName={prop.name}
|
||||
onDelete={() => handleDeleteProp(prop.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{props.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
没有找到匹配的道具
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
显示 {paginatedProps.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
|
||||
{Math.min(currentPage * itemsPerPage, filteredProps.length)} 共 {filteredProps.length} 个道具
|
||||
显示 {props.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
|
||||
{Math.min(currentPage * itemsPerPage, totalItems)} 共 {totalItems} 个道具
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
|
||||
@ -18,7 +18,8 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Loader2, Upload, Video } from "lucide-react"
|
||||
import { Loader2, Trash2 } from "lucide-react"
|
||||
import { FileUpload } from "@/components/ui/file-upload"
|
||||
|
||||
interface AddDanceDialogProps {
|
||||
open: boolean
|
||||
@ -42,6 +43,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
|
||||
tags: [],
|
||||
motionFile: "",
|
||||
videoUrl: "",
|
||||
coverUrl: "",
|
||||
})
|
||||
|
||||
// 预览ID
|
||||
@ -65,6 +67,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
|
||||
tags: editDance.tags || [],
|
||||
motionFile: editDance.motionFile || "",
|
||||
videoUrl: editDance.videoUrl || "",
|
||||
coverUrl: editDance.coverUrl || "",
|
||||
})
|
||||
} else {
|
||||
// 重置表单
|
||||
@ -78,6 +81,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
|
||||
tags: [],
|
||||
motionFile: "",
|
||||
videoUrl: "",
|
||||
coverUrl: "",
|
||||
})
|
||||
// 生成新的预览ID
|
||||
setPreviewId(
|
||||
@ -137,7 +141,7 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
|
||||
tags: formData.tags || [],
|
||||
motionFile: formData.motionFile || "",
|
||||
videoUrl: formData.videoUrl || "",
|
||||
coverUrl: editDance?.coverUrl || "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: formData.coverUrl || editDance?.coverUrl || "/placeholder.svg?height=300&width=400",
|
||||
createdAt: editDance?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
@ -298,20 +302,82 @@ export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }:
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-right">舞蹈封面</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-purple-500 transition-colors cursor-pointer">
|
||||
<Video className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">点击或拖拽上传舞蹈封面</p>
|
||||
<p className="text-xs text-gray-400 mt-1">支持 PNG, JPG, JPEG 格式,最大 5MB</p>
|
||||
</div>
|
||||
{formData.coverUrl && !formData.coverUrl.includes('placeholder') ? (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img
|
||||
src={formData.coverUrl}
|
||||
alt="舞蹈封面"
|
||||
className="h-16 w-16 object-cover rounded border"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">当前封面</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, coverUrl: "" }))}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload
|
||||
imageOnly={true}
|
||||
multiple={false}
|
||||
maxFiles={1}
|
||||
maxSize={5 * 1024 * 1024}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
|
||||
placeholder="点击或拖拽上传舞蹈封面"
|
||||
disabled={isSubmitting}
|
||||
onUploadSuccess={(files) => {
|
||||
if (files.length > 0) {
|
||||
setFormData((prev) => ({ ...prev, coverUrl: files[0].url }))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-right">动作文件上传</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-purple-500 transition-colors cursor-pointer">
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">点击或拖拽上传动作文件</p>
|
||||
<p className="text-xs text-gray-400 mt-1">支持 FBX, BVH 格式,最大 20MB</p>
|
||||
</div>
|
||||
{formData.motionFile ? (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-10 w-10 bg-purple-100 rounded border flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-purple-600">FBX</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-700">当前动作文件</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, motionFile: "" }))}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload
|
||||
imageOnly={false}
|
||||
multiple={false}
|
||||
maxFiles={1}
|
||||
maxSize={20 * 1024 * 1024}
|
||||
accept={{ 'application/octet-stream': ['.fbx', '.bvh'] }}
|
||||
placeholder="点击或拖拽上传动作文件"
|
||||
disabled={isSubmitting}
|
||||
onUploadSuccess={(files) => {
|
||||
if (files.length > 0) {
|
||||
setFormData((prev) => ({ ...prev, motionFile: files[0].url }))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!editDance && (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type React from "react"
|
||||
interface DashboardHeaderProps {
|
||||
heading: string
|
||||
text?: string
|
||||
heading: React.ReactNode
|
||||
text?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ export function DashboardHeader({ heading, text, children }: DashboardHeaderProp
|
||||
<h1 className="font-heading text-3xl md:text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 via-purple-900 to-pink-700">
|
||||
{heading}
|
||||
</h1>
|
||||
{text && <p className="text-lg text-muted-foreground">{text}</p>}
|
||||
{text && <div className="text-lg text-muted-foreground">{text}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -1,15 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Sidebar } from "@/components/sidebar"
|
||||
import { hasPathPermission, getUserRole } from "@/lib/permissions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ShieldX } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface DashboardShellProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function DashboardShell({ children, className, ...props }: DashboardShellProps) {
|
||||
const pathname = usePathname()
|
||||
const [allowed, setAllowed] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setAllowed(hasPathPermission(pathname))
|
||||
}, [pathname])
|
||||
|
||||
const showAccessDenied = allowed === false
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen w-full md:grid-cols-[280px_1fr] bg-gradient-to-br from-gray-50 to-white">
|
||||
<Sidebar />
|
||||
<main className={cn("flex flex-col relative overflow-hidden", className)} {...props}>
|
||||
<div className="flex-1 space-y-6 p-8 pt-6">{children}</div>
|
||||
<div className="flex-1 space-y-6 p-8 pt-6">
|
||||
{showAccessDenied ? (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh]">
|
||||
<ShieldX className="h-16 w-16 text-red-400 mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">无访问权限</h1>
|
||||
<p className="text-gray-500 mb-2">
|
||||
您当前的角色({getUserRole()})没有访问此页面的权限
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mb-6">请联系超级管理员获取相应权限</p>
|
||||
<Button asChild>
|
||||
<Link href="/">返回仪表盘</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -73,7 +73,7 @@ export function AddFoodDialog({
|
||||
setTasteTags(initialFood.taste_tags || "")
|
||||
setNutritionalValue(initialFood.nutritional_value || "")
|
||||
setEffectDescription(initialFood.effect_description || "")
|
||||
setIsLimited(initialFood.food_type?.includes("限定") || false)
|
||||
setIsLimited(initialFood.is_limited || false)
|
||||
setPreviewId(initialFood.id)
|
||||
setImageUrl(initialFood.image)
|
||||
setAnimationUrl(initialFood.animation_file)
|
||||
@ -151,7 +151,7 @@ export function AddFoodDialog({
|
||||
// 构建食物数据
|
||||
const foodData: Partial<Food> = {
|
||||
name,
|
||||
food_type: isLimited ? `限定${foodType}` : foodType,
|
||||
food_type: foodType,
|
||||
rarity,
|
||||
description,
|
||||
image: imageUrl,
|
||||
@ -161,7 +161,8 @@ export function AddFoodDialog({
|
||||
taste_tags: tasteTags,
|
||||
nutritional_value: nutritionalValue,
|
||||
effect_description: effectDescription,
|
||||
status: "published",
|
||||
is_limited: isLimited,
|
||||
...(mode === "create" ? { status: "draft" } : {}),
|
||||
}
|
||||
|
||||
let result: Food
|
||||
@ -258,11 +259,16 @@ export function AddFoodDialog({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fruit">水果</SelectItem>
|
||||
<SelectItem value="dessert">甜点</SelectItem>
|
||||
<SelectItem value="drink">饮品</SelectItem>
|
||||
<SelectItem value="vegetable">蔬菜</SelectItem>
|
||||
<SelectItem value="meat">肉类</SelectItem>
|
||||
<SelectItem value="seafood">海鲜</SelectItem>
|
||||
<SelectItem value="dairy">乳制品</SelectItem>
|
||||
<SelectItem value="grain">谷物</SelectItem>
|
||||
<SelectItem value="snack">零食</SelectItem>
|
||||
<SelectItem value="main_dish">主食</SelectItem>
|
||||
<SelectItem value="special">特殊食物</SelectItem>
|
||||
<SelectItem value="drink">饮品</SelectItem>
|
||||
<SelectItem value="dessert">甜点</SelectItem>
|
||||
<SelectItem value="spice">调味品</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@ -18,8 +18,10 @@ export type Food = {
|
||||
id: string
|
||||
name: string
|
||||
food_type: string
|
||||
food_type_display?: string
|
||||
description: string
|
||||
rarity: string
|
||||
rarity_display?: string
|
||||
image?: string
|
||||
animation_file?: string
|
||||
sound_effect?: string
|
||||
@ -28,6 +30,7 @@ export type Food = {
|
||||
nutritional_value?: string
|
||||
effect_description?: string
|
||||
boost_attributes?: Record<string, number>
|
||||
is_limited?: boolean
|
||||
status: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
|
||||
@ -6,7 +6,6 @@ import { FileUpload } from '@/components/ui/file-upload'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Image, Play, Music, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { UploadResponse } from '@/lib/api/upload'
|
||||
|
||||
interface FoodMediaUploadProps {
|
||||
/** 当前图片URL */
|
||||
@ -39,18 +38,6 @@ export function FoodMediaUpload({
|
||||
}: FoodMediaUploadProps) {
|
||||
const [activeTab, setActiveTab] = useState('image')
|
||||
|
||||
// 准备默认文件列表
|
||||
const getDefaultFiles = (url?: string, type: string = 'image'): UploadResponse[] => {
|
||||
if (!url) return []
|
||||
|
||||
return [{
|
||||
url,
|
||||
filename: `当前${type === 'image' ? '图片' : type === 'animation' ? '动画' : '音频'}`,
|
||||
size: 0,
|
||||
mimeType: type === 'image' ? 'image/jpeg' : type === 'animation' ? 'video/mp4' : 'audio/mp3'
|
||||
}]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
@ -82,30 +69,13 @@ export function FoodMediaUpload({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FileUpload
|
||||
imageOnly={true}
|
||||
multiple={false}
|
||||
maxFiles={1}
|
||||
maxSize={5 * 1024 * 1024} // 5MB
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
|
||||
placeholder="选择或拖拽食物图片"
|
||||
defaultFiles={getDefaultFiles(imageUrl, 'image')}
|
||||
disabled={disabled}
|
||||
onUploadSuccess={(files) => {
|
||||
if (files.length > 0) {
|
||||
onImageUpload?.(files[0].url)
|
||||
}
|
||||
}}
|
||||
onRemove={() => onRemove?.('image')}
|
||||
/>
|
||||
|
||||
{imageUrl && (
|
||||
<div className="mt-4 flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
{imageUrl ? (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="食物图片预览"
|
||||
className="h-12 w-12 object-cover rounded border"
|
||||
className="h-16 w-16 object-cover rounded border"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">当前图片</span>
|
||||
</div>
|
||||
@ -115,10 +85,27 @@ export function FoodMediaUpload({
|
||||
size="sm"
|
||||
onClick={() => onRemove?.('image')}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload
|
||||
imageOnly={true}
|
||||
multiple={false}
|
||||
maxFiles={1}
|
||||
maxSize={5 * 1024 * 1024}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
|
||||
placeholder="选择或拖拽食物图片"
|
||||
disabled={disabled}
|
||||
onUploadSuccess={(files) => {
|
||||
if (files.length > 0) {
|
||||
onImageUpload?.(files[0].url)
|
||||
}
|
||||
}}
|
||||
onRemove={() => onRemove?.('image')}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -137,48 +124,26 @@ export function FoodMediaUpload({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FileUpload
|
||||
imageOnly={false}
|
||||
multiple={false}
|
||||
maxFiles={1}
|
||||
maxSize={50 * 1024 * 1024} // 50MB
|
||||
accept={{
|
||||
'video/*': ['.mp4', '.avi', '.mov', '.wmv', '.flv'],
|
||||
'image/gif': ['.gif'],
|
||||
'application/json': ['.json'],
|
||||
'application/x-lottie': ['.lottie']
|
||||
}}
|
||||
placeholder="选择或拖拽动画文件"
|
||||
defaultFiles={getDefaultFiles(animationUrl, 'animation')}
|
||||
disabled={disabled}
|
||||
onUploadSuccess={(files) => {
|
||||
if (files.length > 0) {
|
||||
onAnimationUpload?.(files[0].url)
|
||||
}
|
||||
}}
|
||||
onRemove={() => onRemove?.('animation')}
|
||||
/>
|
||||
|
||||
{animationUrl && (
|
||||
<div className="mt-4 flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
{animationUrl ? (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
|
||||
<div className="flex items-center space-x-3">
|
||||
{animationUrl.endsWith('.gif') ? (
|
||||
<img
|
||||
src={animationUrl}
|
||||
alt="动画预览"
|
||||
className="h-12 w-12 object-cover rounded border"
|
||||
className="h-16 w-16 object-cover rounded border"
|
||||
/>
|
||||
) : animationUrl.match(/\.(mp4|avi|mov|wmv|flv)$/i) ? (
|
||||
<video
|
||||
src={animationUrl}
|
||||
className="h-12 w-12 object-cover rounded border"
|
||||
className="h-16 w-16 object-cover rounded border"
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 bg-gray-200 rounded border flex items-center justify-center">
|
||||
<Play className="h-4 w-4 text-gray-500" />
|
||||
<div className="h-16 w-16 bg-gray-200 rounded border flex items-center justify-center">
|
||||
<Play className="h-6 w-6 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-gray-700">当前动画</span>
|
||||
@ -189,10 +154,32 @@ export function FoodMediaUpload({
|
||||
size="sm"
|
||||
onClick={() => onRemove?.('animation')}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload
|
||||
imageOnly={false}
|
||||
multiple={false}
|
||||
maxFiles={1}
|
||||
maxSize={50 * 1024 * 1024}
|
||||
accept={{
|
||||
'video/*': ['.mp4', '.avi', '.mov', '.wmv', '.flv'],
|
||||
'image/gif': ['.gif'],
|
||||
'application/json': ['.json'],
|
||||
'application/x-lottie': ['.lottie']
|
||||
}}
|
||||
placeholder="选择或拖拽动画文件"
|
||||
disabled={disabled}
|
||||
onUploadSuccess={(files) => {
|
||||
if (files.length > 0) {
|
||||
onAnimationUpload?.(files[0].url)
|
||||
}
|
||||
}}
|
||||
onRemove={() => onRemove?.('animation')}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -211,30 +198,11 @@ export function FoodMediaUpload({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FileUpload
|
||||
imageOnly={false}
|
||||
multiple={false}
|
||||
maxFiles={1}
|
||||
maxSize={20 * 1024 * 1024} // 20MB
|
||||
accept={{
|
||||
'audio/*': ['.mp3', '.wav', '.ogg', '.aac', '.flac', '.wma', '.m4a']
|
||||
}}
|
||||
placeholder="选择或拖拽音频文件"
|
||||
defaultFiles={getDefaultFiles(audioUrl, 'audio')}
|
||||
disabled={disabled}
|
||||
onUploadSuccess={(files) => {
|
||||
if (files.length > 0) {
|
||||
onAudioUpload?.(files[0].url)
|
||||
}
|
||||
}}
|
||||
onRemove={() => onRemove?.('audio')}
|
||||
/>
|
||||
|
||||
{audioUrl && (
|
||||
<div className="mt-4 flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
{audioUrl ? (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-12 w-12 bg-blue-100 rounded border flex items-center justify-center">
|
||||
<Music className="h-4 w-4 text-blue-500" />
|
||||
<div className="h-16 w-16 bg-blue-100 rounded border flex items-center justify-center">
|
||||
<Music className="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-gray-700 block">当前音频</span>
|
||||
@ -250,10 +218,29 @@ export function FoodMediaUpload({
|
||||
size="sm"
|
||||
onClick={() => onRemove?.('audio')}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload
|
||||
imageOnly={false}
|
||||
multiple={false}
|
||||
maxFiles={1}
|
||||
maxSize={20 * 1024 * 1024}
|
||||
accept={{
|
||||
'audio/*': ['.mp3', '.wav', '.ogg', '.aac', '.flac', '.wma', '.m4a']
|
||||
}}
|
||||
placeholder="选择或拖拽音频文件"
|
||||
disabled={disabled}
|
||||
onUploadSuccess={(files) => {
|
||||
if (files.length > 0) {
|
||||
onAudioUpload?.(files[0].url)
|
||||
}
|
||||
}}
|
||||
onRemove={() => onRemove?.('audio')}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -15,9 +15,25 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Plus, Upload, AlertTriangle, Loader2 } from "lucide-react"
|
||||
import { Plus, AlertTriangle, Loader2, Trash2 } from "lucide-react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { FileUpload } from "@/components/ui/file-upload"
|
||||
import type { HomeDecor } from "./home-decor-detail-dialog"
|
||||
import { createHomeDecor, updateHomeDecor } from "@/lib/api/home-decor"
|
||||
|
||||
// 判断是否为有效图片URL(非placeholder)
|
||||
function isRealImageUrl(url?: string): boolean {
|
||||
if (!url) return false
|
||||
return !url.includes("placeholder")
|
||||
}
|
||||
|
||||
// 中文稀有度 -> 后端 rarity key
|
||||
const RARITY_MAP: Record<string, string> = {
|
||||
"普通": "common",
|
||||
"稀有": "rare",
|
||||
"史诗": "epic",
|
||||
"传说": "legendary",
|
||||
}
|
||||
|
||||
type AddHomeDecorDialogProps = {
|
||||
mode?: "create" | "edit"
|
||||
@ -43,6 +59,7 @@ export function AddHomeDecorDialog({
|
||||
const [rarity, setRarity] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [imageUrl, setImageUrl] = useState<string | undefined>()
|
||||
const [previewId, setPreviewId] = useState(
|
||||
"DEC" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
@ -59,6 +76,7 @@ export function AddHomeDecorDialog({
|
||||
setDescription(initialDecor.description)
|
||||
setIsLimited(initialDecor.type.includes("限定"))
|
||||
setPreviewId(initialDecor.id)
|
||||
setImageUrl(isRealImageUrl(initialDecor.image) ? initialDecor.image : undefined)
|
||||
}
|
||||
}, [mode, initialDecor])
|
||||
|
||||
@ -90,6 +108,7 @@ export function AddHomeDecorDialog({
|
||||
setRarity("")
|
||||
setDescription("")
|
||||
setIsLimited(false)
|
||||
setImageUrl(undefined)
|
||||
setPreviewId(
|
||||
"DEC" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
@ -108,26 +127,58 @@ export function AddHomeDecorDialog({
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
// 构建装饰对象
|
||||
const decor: HomeDecor = {
|
||||
id: initialDecor?.id || previewId,
|
||||
name,
|
||||
type: isLimited ? `限定${decorType}` : decorType,
|
||||
rarity,
|
||||
description,
|
||||
releaseDate: initialDecor?.releaseDate || "",
|
||||
status: initialDecor?.status || "未发布",
|
||||
activatedCount: initialDecor?.activatedCount || 0,
|
||||
// 不使用外部图片URL,避免加载错误
|
||||
image: undefined,
|
||||
}
|
||||
const actualType = isLimited ? `限定${decorType}` : decorType
|
||||
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
if (mode === "create") {
|
||||
// 调用后端创建 API
|
||||
const created = await createHomeDecor({
|
||||
name,
|
||||
description,
|
||||
category: actualType,
|
||||
rarityValue: RARITY_MAP[rarity] || rarity,
|
||||
imageUrl,
|
||||
})
|
||||
|
||||
// 调用保存回调
|
||||
if (onSave) {
|
||||
onSave(decor)
|
||||
// 构建组件格式的 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",
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
onSave(decor)
|
||||
}
|
||||
} else {
|
||||
// 调用后端更新 API
|
||||
const updated = await updateHomeDecor(initialDecor!.id, {
|
||||
name,
|
||||
description,
|
||||
category: actualType,
|
||||
imageUrl,
|
||||
})
|
||||
|
||||
const decor: HomeDecor = {
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
type: updated.category || actualType,
|
||||
rarity: updated.rarity || rarity,
|
||||
description: updated.description || description,
|
||||
releaseDate: initialDecor?.releaseDate || "",
|
||||
status: updated.status || initialDecor?.status || "未发布",
|
||||
activatedCount: updated.activeCardsCount || initialDecor?.activatedCount || 0,
|
||||
image: updated.imageUrl || initialDecor?.image || "/placeholder.svg?height=300&width=300",
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
onSave(decor)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
@ -247,11 +298,43 @@ export function AddHomeDecorDialog({
|
||||
<Label className="text-right">
|
||||
装饰图片 {mode === "create" && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">点击或拖拽上传装饰图片</p>
|
||||
<p className="text-xs text-gray-400 mt-1">支持 PNG, JPG, JPEG 格式,最大 5MB</p>
|
||||
</div>
|
||||
{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">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@ -15,19 +15,59 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Plus, Upload, AlertTriangle, Loader2 } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Plus, AlertTriangle, Loader2, Trash2 } from "lucide-react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FileUpload } from "@/components/ui/file-upload"
|
||||
import { createOutfit, updateOutfit } from "@/lib/api/outfits"
|
||||
|
||||
export function AddOutfitDialog() {
|
||||
function isRealImageUrl(url?: string): boolean {
|
||||
if (!url) return false
|
||||
return !url.includes("placeholder")
|
||||
}
|
||||
|
||||
const RARITY_MAP: Record<string, string> = {
|
||||
"普通": "common",
|
||||
"稀有": "rare",
|
||||
"史诗": "epic",
|
||||
"传说": "legendary",
|
||||
}
|
||||
|
||||
export type DisplayOutfit = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
rarity: string
|
||||
description: string
|
||||
releaseDate: string
|
||||
status: string
|
||||
activatedCount: number
|
||||
image?: string
|
||||
}
|
||||
|
||||
type AddOutfitDialogProps = {
|
||||
mode?: "create" | "edit"
|
||||
initialOutfit?: DisplayOutfit
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSave?: (outfit: DisplayOutfit) => void
|
||||
}
|
||||
|
||||
export function AddOutfitDialog({
|
||||
mode = "create",
|
||||
initialOutfit,
|
||||
open: controlledOpen,
|
||||
onOpenChange: setControlledOpen,
|
||||
onSave,
|
||||
}: AddOutfitDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(1)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [outfitType, setOutfitType] = useState("")
|
||||
const [rarity, setRarity] = useState("")
|
||||
const [printQuantity, setPrintQuantity] = useState(1000)
|
||||
const [description, setDescription] = useState("")
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [imageUrl, setImageUrl] = useState<string | undefined>()
|
||||
const [previewId, setPreviewId] = useState(
|
||||
"OFT" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
@ -35,309 +75,312 @@ export function AddOutfitDialog() {
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && initialOutfit) {
|
||||
setName(initialOutfit.name)
|
||||
setOutfitType(initialOutfit.type)
|
||||
setRarity(initialOutfit.rarity)
|
||||
setDescription(initialOutfit.description)
|
||||
setIsLimited(initialOutfit.type === "限定服装")
|
||||
setPreviewId(initialOutfit.id)
|
||||
setImageUrl(isRealImageUrl(initialOutfit.image) ? initialOutfit.image : undefined)
|
||||
}
|
||||
}, [mode, initialOutfit])
|
||||
|
||||
useEffect(() => {
|
||||
if (controlledOpen !== undefined) {
|
||||
setOpen(controlledOpen)
|
||||
}
|
||||
}, [controlledOpen])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
if (setControlledOpen) {
|
||||
setControlledOpen(newOpen)
|
||||
}
|
||||
if (!newOpen && mode === "create") {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
if (mode === "create") {
|
||||
setName("")
|
||||
setOutfitType("")
|
||||
setRarity("")
|
||||
setDescription("")
|
||||
setIsLimited(false)
|
||||
setImageUrl(undefined)
|
||||
setPreviewId(
|
||||
"OFT" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name || !outfitType || !rarity || !description) {
|
||||
alert("请填写所有必填字段!")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
setIsSubmitting(false)
|
||||
setOpen(false)
|
||||
// 重置表单
|
||||
setStep(1)
|
||||
setOutfitType("")
|
||||
setRarity("")
|
||||
setPrintQuantity(1000)
|
||||
setIsLimited(false)
|
||||
setPreviewId(
|
||||
"OFT" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
}
|
||||
try {
|
||||
const actualType = isLimited ? "限定服装" : outfitType
|
||||
|
||||
const handleNext = () => {
|
||||
setStep(step + 1)
|
||||
}
|
||||
if (mode === "create") {
|
||||
const created = await createOutfit({
|
||||
name,
|
||||
description,
|
||||
category: actualType,
|
||||
rarityValue: RARITY_MAP[rarity] || rarity,
|
||||
imageUrl,
|
||||
})
|
||||
|
||||
const handleBack = () => {
|
||||
setStep(step - 1)
|
||||
}
|
||||
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 || "",
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
setStep(1)
|
||||
if (onSave) {
|
||||
onSave(outfit)
|
||||
}
|
||||
} else {
|
||||
const updated = await updateOutfit(initialOutfit!.id, {
|
||||
name,
|
||||
description,
|
||||
category: actualType,
|
||||
imageUrl,
|
||||
})
|
||||
|
||||
const outfit: DisplayOutfit = {
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
type: updated.category || actualType,
|
||||
rarity: updated.rarity || rarity,
|
||||
description: updated.description || description,
|
||||
releaseDate: initialOutfit?.releaseDate || "",
|
||||
status: updated.status || initialOutfit?.status || "未发布",
|
||||
activatedCount: updated.activeCardsCount || initialOutfit?.activatedCount || 0,
|
||||
image: updated.imageUrl || initialOutfit?.image || "",
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
onSave(outfit)
|
||||
}
|
||||
}
|
||||
|
||||
handleOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error(mode === "create" ? "创建服装失败:" : "更新服装失败:", error)
|
||||
alert(mode === "create" ? "创建服装失败,请重试!" : "更新服装失败,请重试!")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<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]">
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
{mode === "create" && (
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加服装
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-[550px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
添加新服装
|
||||
{mode === "create" ? "添加新服装" : "编辑服装"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>填写服装信息以创建新的服装卡牌。创建后将生成唯一的卡牌ID。</DialogDescription>
|
||||
<DialogDescription>
|
||||
{mode === "create" ? "填写服装信息以创建新的服装卡牌。创建后将生成唯一的卡牌ID。" : "修改服装信息。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={`step-${step}`} className="mt-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger
|
||||
value="step-1"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white",
|
||||
step >= 1 ? "text-pink-600" : "text-gray-500",
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
基本信息
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="step-2"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white",
|
||||
step >= 2 ? "text-pink-600" : "text-gray-500",
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
图片上传
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="step-3"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-gradient-to-r data-[state=active]:from-pink-500 data-[state=active]:to-purple-600 data-[state=active]:text-white",
|
||||
step >= 3 ? "text-pink-600" : "text-gray-500",
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
确认创建
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="step-1" className="space-y-4 py-4">
|
||||
<div className="grid 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="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
服装描述 <span className="text-red-500">*</span>
|
||||
<Label htmlFor="name" 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"
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="输入服装名称"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="limited" checked={isLimited} onCheckedChange={setIsLimited} />
|
||||
<Label htmlFor="limited" className="cursor-pointer">
|
||||
这是限定服装
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{isLimited && (
|
||||
<div className="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" />
|
||||
<div className="text-sm text-amber-700">
|
||||
<p className="font-medium">限定服装提示</p>
|
||||
<p>限定服装通常有特定的发布时间和限制条件。请确保在发布前设置好相关参数。</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step-2" className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-right">
|
||||
服装图片 <span className="text-red-500">*</span>
|
||||
<Label htmlFor="type" 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>
|
||||
<Select value={outfitType} onValueChange={setOutfitType} required>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue placeholder="选择服装类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="常规服装">常规服装</SelectItem>
|
||||
<SelectItem value="演出服装">演出服装</SelectItem>
|
||||
<SelectItem value="季节限定">季节限定</SelectItem>
|
||||
<SelectItem value="节日限定">节日限定</SelectItem>
|
||||
<SelectItem value="限定服装">限定服装</SelectItem>
|
||||
<SelectItem value="其他">其他</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="普通">普通</SelectItem>
|
||||
<SelectItem value="稀有">稀有</SelectItem>
|
||||
<SelectItem value="史诗">史诗</SelectItem>
|
||||
<SelectItem value="传说">传说</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity" className="text-right">
|
||||
初始数量 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue="1000"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
required
|
||||
disabled={mode === "edit"}
|
||||
/>
|
||||
</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"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-right">
|
||||
服装图片 {mode === "create" && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
{imageUrl ? (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="服装图片"
|
||||
className="h-16 w-16 object-cover rounded border"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">当前图片</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setImageUrl(undefined)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload
|
||||
imageOnly={true}
|
||||
multiple={false}
|
||||
maxFiles={1}
|
||||
maxSize={5 * 1024 * 1024}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
|
||||
placeholder="点击或拖拽上传服装图片"
|
||||
disabled={isSubmitting}
|
||||
onUploadSuccess={(files) => {
|
||||
if (files.length > 0) {
|
||||
setImageUrl(files[0].url)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="limited" checked={isLimited} onCheckedChange={setIsLimited} />
|
||||
<Label htmlFor="limited" className="cursor-pointer">
|
||||
这是限定服装
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{isLimited && (
|
||||
<div className="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" />
|
||||
<div className="text-sm text-amber-700">
|
||||
<p className="font-medium">限定服装提示</p>
|
||||
<p>限定服装通常有特定的发布时间和限制条件。请确保在发布前设置好相关参数。</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label className="text-right">服装预览图 (可选)</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">点击或拖拽上传服装预览图</p>
|
||||
<p className="text-xs text-gray-400 mt-1">建议上传多角度预览图,最多5张</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start mt-2">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium">图片要求</p>
|
||||
<p>主图将用于卡牌印刷和APP展示,请确保图片清晰、比例合适。建议使用透明背景的PNG格式。</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step-3" className="space-y-4 py-4">
|
||||
{mode === "create" && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="text-lg font-medium mb-3">服装信息确认</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">服装ID</p>
|
||||
<p className="font-medium text-pink-600">{previewId}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">服装类型</p>
|
||||
<p className="font-medium">
|
||||
{outfitType === "regular"
|
||||
? "常规"
|
||||
: outfitType === "seasonal"
|
||||
? "季节限定"
|
||||
: outfitType === "festival"
|
||||
? "节日限定"
|
||||
: outfitType === "special"
|
||||
? "特别版"
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">稀有度</p>
|
||||
<p className="font-medium">
|
||||
{rarity === "common"
|
||||
? "普通"
|
||||
: rarity === "rare"
|
||||
? "稀有"
|
||||
: rarity === "epic"
|
||||
? "史诗"
|
||||
: rarity === "legendary"
|
||||
? "传说"
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">印刷数量</p>
|
||||
<p className="font-medium">{printQuantity}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">限定服装</p>
|
||||
<p className="font-medium">{isLimited ? "是" : "否"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">状态</p>
|
||||
<p className="font-medium">未发布</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-pink-50 border border-pink-200 rounded-lg flex items-start">
|
||||
<AlertTriangle className="h-5 w-5 text-pink-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-pink-700">
|
||||
<p className="font-medium">重要提示</p>
|
||||
<p>创建服装后,系统将自动生成卡牌ID。服装发布后将无法修改基本属性,请确认信息无误。</p>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium mb-2">预览信息</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
服装ID: <span className="font-medium text-pink-600">{previewId}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
状态: <span className="font-medium">未发布</span>
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
|
||||
onClick={handleNext}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
"创建服装"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(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 ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{mode === "create" ? "创建中..." : "更新中..."}
|
||||
</>
|
||||
) : mode === "create" ? (
|
||||
"创建服装"
|
||||
) : (
|
||||
"更新服装"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -15,9 +15,25 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Plus, Upload, AlertTriangle, Loader2 } from "lucide-react"
|
||||
import { Plus, AlertTriangle, Loader2, Trash2 } from "lucide-react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { FileUpload } from "@/components/ui/file-upload"
|
||||
import type { Prop } from "./prop-detail-dialog"
|
||||
import { createProp, updateProp } from "@/lib/api/props"
|
||||
|
||||
// 判断是否为有效图片URL(非placeholder)
|
||||
function isRealImageUrl(url?: string): boolean {
|
||||
if (!url) return false
|
||||
return !url.includes("placeholder")
|
||||
}
|
||||
|
||||
// 中文稀有度 -> 后端 rarity key
|
||||
const RARITY_MAP: Record<string, string> = {
|
||||
"普通": "common",
|
||||
"稀有": "rare",
|
||||
"史诗": "epic",
|
||||
"传说": "legendary",
|
||||
}
|
||||
|
||||
type AddPropDialogProps = {
|
||||
mode?: "create" | "edit"
|
||||
@ -43,6 +59,7 @@ export function AddPropDialog({
|
||||
const [rarity, setRarity] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [imageUrl, setImageUrl] = useState<string | undefined>()
|
||||
const [previewId, setPreviewId] = useState(
|
||||
"PRP" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
@ -59,6 +76,7 @@ export function AddPropDialog({
|
||||
setDescription(initialProp.description)
|
||||
setIsLimited(initialProp.type === "限定道具")
|
||||
setPreviewId(initialProp.id)
|
||||
setImageUrl(isRealImageUrl(initialProp.image) ? initialProp.image : undefined)
|
||||
}
|
||||
}, [mode, initialProp])
|
||||
|
||||
@ -90,6 +108,7 @@ export function AddPropDialog({
|
||||
setRarity("")
|
||||
setDescription("")
|
||||
setIsLimited(false)
|
||||
setImageUrl(undefined)
|
||||
setPreviewId(
|
||||
"PRP" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
@ -108,25 +127,58 @@ export function AddPropDialog({
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
// 构建道具对象
|
||||
const prop: Prop = {
|
||||
id: initialProp?.id || previewId,
|
||||
name,
|
||||
type: isLimited ? "限定道具" : propType,
|
||||
rarity,
|
||||
description,
|
||||
releaseDate: initialProp?.releaseDate || "",
|
||||
status: initialProp?.status || "未发布",
|
||||
activatedCount: initialProp?.activatedCount || 0,
|
||||
image: initialProp?.image || "/placeholder.svg?height=300&width=300",
|
||||
}
|
||||
const actualType = isLimited ? "限定道具" : propType
|
||||
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
if (mode === "create") {
|
||||
// 调用后端创建 API
|
||||
const created = await createProp({
|
||||
name,
|
||||
description,
|
||||
category: actualType,
|
||||
rarityValue: RARITY_MAP[rarity] || rarity,
|
||||
imageUrl,
|
||||
})
|
||||
|
||||
// 调用保存回调
|
||||
if (onSave) {
|
||||
onSave(prop)
|
||||
// 构建组件格式的 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",
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
onSave(prop)
|
||||
}
|
||||
} else {
|
||||
// 调用后端更新 API
|
||||
const updated = await updateProp(initialProp!.id, {
|
||||
name,
|
||||
description,
|
||||
category: actualType,
|
||||
imageUrl,
|
||||
})
|
||||
|
||||
const prop: Prop = {
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
type: updated.category || actualType,
|
||||
rarity: updated.rarity || rarity,
|
||||
description: updated.description || description,
|
||||
releaseDate: initialProp?.releaseDate || "",
|
||||
status: updated.status || initialProp?.status || "未发布",
|
||||
activatedCount: updated.activeCardsCount || initialProp?.activatedCount || 0,
|
||||
image: updated.imageUrl || initialProp?.image || "/placeholder.svg?height=300&width=300",
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
onSave(prop)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
@ -243,11 +295,43 @@ export function AddPropDialog({
|
||||
<Label className="text-right">
|
||||
道具图片 {mode === "create" && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">点击或拖拽上传道具图片</p>
|
||||
<p className="text-xs text-gray-400 mt-1">支持 PNG, JPG, JPEG 格式,最大 5MB</p>
|
||||
</div>
|
||||
{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">
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { logout } from "@/lib/api/auth"
|
||||
import { hasPermission, getUserRole, type PermissionModule } from "@/lib/permissions"
|
||||
import {
|
||||
Brain,
|
||||
Music,
|
||||
@ -21,24 +23,88 @@ import {
|
||||
Heart,
|
||||
Footprints,
|
||||
Trophy,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
interface MenuItem {
|
||||
label: string
|
||||
href: string
|
||||
icon: LucideIcon
|
||||
module: PermissionModule
|
||||
}
|
||||
|
||||
// AI 管理
|
||||
const aiMenuItems: MenuItem[] = [
|
||||
{ label: "大模型管理", href: "/ai-model", icon: Brain, module: "ai-model" },
|
||||
]
|
||||
|
||||
// 内容管理
|
||||
const contentMenuItems: MenuItem[] = [
|
||||
{ label: "服装管理", href: "/outfits", icon: Shirt, module: "outfits" },
|
||||
{ label: "道具管理", href: "/props", icon: Gift, module: "props" },
|
||||
{ label: "家居装饰管理", href: "/home-decor", icon: Home, module: "home-decor" },
|
||||
{ label: "歌曲管理", href: "/songs", icon: Music, module: "songs" },
|
||||
{ label: "舞蹈管理", href: "/dances", icon: Footprints, module: "dances" },
|
||||
{ label: "食物管理", href: "/food", icon: Utensils, module: "food" },
|
||||
{ label: "成就管理", href: "/achievements", icon: Trophy, module: "achievements" },
|
||||
{ label: "好感度系统", href: "/affinity", icon: Heart, module: "affinity" },
|
||||
]
|
||||
|
||||
// 系统管理
|
||||
const systemMenuItems: MenuItem[] = [
|
||||
{ label: "用户管理", href: "/users", icon: User, module: "users" },
|
||||
{ label: "权限管理", href: "/permissions", icon: Lock, module: "permissions" },
|
||||
{ label: "系统设置", href: "/settings", icon: Settings, module: "settings" },
|
||||
]
|
||||
|
||||
function NavButton({ item, pathname }: { item: MenuItem; pathname: string }) {
|
||||
const isActive = pathname === item.href
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start",
|
||||
isActive
|
||||
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
|
||||
: "hover:bg-gray-100",
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href={item.href}>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// 调用退出登录API
|
||||
await logout()
|
||||
|
||||
// 退出后重定向到登录页面
|
||||
router.push("/login")
|
||||
} catch (error) {
|
||||
console.error("退出登录失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据权限过滤菜单项(挂载后才读 localStorage)
|
||||
const visibleAiItems = mounted ? aiMenuItems.filter((item) => hasPermission(item.module)) : []
|
||||
const visibleContentItems = mounted ? contentMenuItems.filter((item) => hasPermission(item.module)) : []
|
||||
const visibleSystemItems = mounted ? systemMenuItems.filter((item) => hasPermission(item.module)) : []
|
||||
|
||||
const role = mounted ? getUserRole() : ""
|
||||
|
||||
return (
|
||||
<div className="flex h-screen border-r bg-gradient-to-b from-white to-gray-50 shadow-md">
|
||||
<div className="flex w-full flex-col space-y-4 p-4">
|
||||
@ -47,13 +113,17 @@ export function Sidebar() {
|
||||
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center">
|
||||
<Sparkles className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-pink-600 to-purple-600">
|
||||
洛天依管理系统
|
||||
</h2>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-pink-600 to-purple-600">
|
||||
洛天依管理系统
|
||||
</h2>
|
||||
<p className="text-xs text-gray-400">{role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{/* 仪表盘 - 所有角色都可见 */}
|
||||
<Button
|
||||
variant={pathname === "/" ? "default" : "ghost"}
|
||||
className={cn(
|
||||
@ -70,209 +140,41 @@ export function Sidebar() {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="pt-4 pb-2">
|
||||
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">AI 管理</p>
|
||||
</div>
|
||||
{/* AI 管理 */}
|
||||
{visibleAiItems.length > 0 && (
|
||||
<>
|
||||
<div className="pt-4 pb-2">
|
||||
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">AI 管理</p>
|
||||
</div>
|
||||
{visibleAiItems.map((item) => (
|
||||
<NavButton key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={pathname === "/ai-model" ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start",
|
||||
pathname === "/ai-model"
|
||||
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
|
||||
: "hover:bg-gray-100",
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/ai-model">
|
||||
<Brain className="mr-2 h-4 w-4" />
|
||||
大模型管理
|
||||
</Link>
|
||||
</Button>
|
||||
{/* 内容管理 */}
|
||||
{visibleContentItems.length > 0 && (
|
||||
<>
|
||||
<div className="pt-4 pb-2">
|
||||
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">内容管理</p>
|
||||
</div>
|
||||
{visibleContentItems.map((item) => (
|
||||
<NavButton key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="pt-4 pb-2">
|
||||
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">内容管理</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={pathname === "/outfits" ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start",
|
||||
pathname === "/outfits"
|
||||
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
|
||||
: "hover:bg-gray-100",
|
||||
)}
|
||||
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>
|
||||
|
||||
<div className="pt-4 pb-2">
|
||||
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">系统管理</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={pathname === "/users" ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start",
|
||||
pathname === "/users"
|
||||
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
|
||||
: "hover:bg-gray-100",
|
||||
)}
|
||||
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>
|
||||
{/* 系统管理 */}
|
||||
{visibleSystemItems.length > 0 && (
|
||||
<>
|
||||
<div className="pt-4 pb-2">
|
||||
<p className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">系统管理</p>
|
||||
</div>
|
||||
{visibleSystemItems.map((item) => (
|
||||
<NavButton key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-4">
|
||||
|
||||
@ -15,7 +15,8 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Plus, Upload, Loader2, Music } from "lucide-react"
|
||||
import { Plus, Loader2, Music, Trash2 } from "lucide-react"
|
||||
import { FileUpload } from "@/components/ui/file-upload"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import type { Song } from "./song-detail-dialog"
|
||||
import { uploadSongFile, createSong, updateSong } from "@/lib/api/songs"
|
||||
@ -53,6 +54,7 @@ export function AddSongDialog({
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
const [coverUrl, setCoverUrl] = useState<string>("")
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null)
|
||||
const [audioUrl, setAudioUrl] = useState<string>("")
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
@ -85,6 +87,11 @@ export function AddSongDialog({
|
||||
setDescription(initialSong.description)
|
||||
}
|
||||
|
||||
// 初始化封面URL
|
||||
if (initialSong.image) {
|
||||
setCoverUrl(initialSong.image)
|
||||
}
|
||||
|
||||
// 初始化音频URL
|
||||
if (initialSong.audioUrl) {
|
||||
setAudioUrl(initialSong.audioUrl)
|
||||
@ -187,7 +194,7 @@ export function AddSongDialog({
|
||||
|
||||
if (mode === "create") {
|
||||
// 创建模式
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
name,
|
||||
category: "song",
|
||||
card_type: "regular",
|
||||
@ -197,15 +204,21 @@ export function AddSongDialog({
|
||||
description: description || undefined,
|
||||
song_attributes: songAttributes
|
||||
}
|
||||
if (coverUrl) {
|
||||
payload.image_url = coverUrl
|
||||
}
|
||||
const created = await createSong(payload)
|
||||
if (onSave) onSave(created)
|
||||
} else if (mode === "edit" && initialSong) {
|
||||
// 编辑模式
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
name,
|
||||
category: "song",
|
||||
song_attributes: songAttributes
|
||||
}
|
||||
if (coverUrl) {
|
||||
payload.image_url = coverUrl
|
||||
}
|
||||
const updated = await updateSong(initialSong.id, payload)
|
||||
if (onSave) onSave(updated)
|
||||
}
|
||||
@ -299,10 +312,43 @@ export function AddSongDialog({
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">歌曲封面</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-md p-2 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer min-h-[60px]">
|
||||
<Upload className="h-5 w-5 text-gray-400 mb-1" />
|
||||
<span className="text-xs text-gray-500">上传</span>
|
||||
</div>
|
||||
{coverUrl && !coverUrl.includes("placeholder") ? (
|
||||
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md border min-h-[60px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt="歌曲封面"
|
||||
className="h-10 w-10 object-cover rounded border"
|
||||
/>
|
||||
<span className="text-xs text-gray-700">已上传</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCoverUrl("")}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 h-6 w-6 p-0"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload
|
||||
imageOnly={true}
|
||||
multiple={false}
|
||||
maxFiles={1}
|
||||
maxSize={5 * 1024 * 1024}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
|
||||
placeholder="上传封面"
|
||||
disabled={isSubmitting}
|
||||
onUploadSuccess={(files) => {
|
||||
if (files.length > 0) {
|
||||
setCoverUrl(files[0].url)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 可折叠描述和预览信息 */}
|
||||
|
||||
@ -1,266 +1,117 @@
|
||||
import type { Achievement } from "./types"
|
||||
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
|
||||
import { apiClient } from "./client"
|
||||
import type { PaginatedResponse, PaginationParams } from "./client"
|
||||
|
||||
// 模拟成就数据
|
||||
const mockAchievements: Achievement[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "初次见面",
|
||||
description: "第一次与洛天依对话",
|
||||
icon: "message-circle",
|
||||
category: "互动",
|
||||
requirement: "与洛天依进行第一次对话",
|
||||
// 将后端成就数据映射到前端 Achievement 类型
|
||||
function mapBackendAchievement(a: any): Achievement {
|
||||
// 后端 achievement_type → 前端 category 映射
|
||||
const categoryMap: Record<string, Achievement["category"]> = {
|
||||
login: "互动",
|
||||
activity: "互动",
|
||||
social: "互动",
|
||||
usage: "收集",
|
||||
special: "特殊",
|
||||
hidden: "隐藏",
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(a.id),
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
icon: undefined,
|
||||
category: categoryMap[a.achievement_type] || "特殊",
|
||||
requirement: a.conditions ? JSON.stringify(a.conditions) : "",
|
||||
rewardType: "经验值",
|
||||
rewardAmount: 100,
|
||||
rewardIcon: "zap",
|
||||
isHidden: false,
|
||||
unlockRate: 98.5,
|
||||
createdAt: "2023-01-01",
|
||||
updatedAt: "2023-01-01",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "亲密无间",
|
||||
description: "与洛天依好感度达到80",
|
||||
icon: "heart",
|
||||
category: "好感度",
|
||||
requirement: "将洛天依的好感度提升至80",
|
||||
rewardType: "虚拟币",
|
||||
rewardAmount: 500,
|
||||
rewardIcon: "coins",
|
||||
isHidden: false,
|
||||
unlockRate: 45.2,
|
||||
createdAt: "2023-01-02",
|
||||
updatedAt: "2023-01-02",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "形影不离",
|
||||
description: "与洛天依好感度达到100",
|
||||
icon: "heart-handshake",
|
||||
category: "好感度",
|
||||
requirement: "将洛天依的好感度提升至100",
|
||||
rewardType: "称号",
|
||||
rewardAmount: 1,
|
||||
rewardIcon: "badge",
|
||||
isHidden: false,
|
||||
unlockRate: 12.8,
|
||||
createdAt: "2023-01-03",
|
||||
updatedAt: "2023-01-03",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "热情如火",
|
||||
description: "与洛天依单日互动达到50次",
|
||||
icon: "flame",
|
||||
category: "互动",
|
||||
requirement: "在一天内与洛天依互动50次",
|
||||
rewardType: "好感度",
|
||||
rewardAmount: 20,
|
||||
rewardIcon: "heart",
|
||||
isHidden: false,
|
||||
unlockRate: 23.7,
|
||||
createdAt: "2023-01-04",
|
||||
updatedAt: "2023-01-04",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "坚持不懈",
|
||||
description: "连续7天与洛天依互动",
|
||||
icon: "calendar-check",
|
||||
category: "互动",
|
||||
requirement: "连续7天每天至少与洛天依互动1次",
|
||||
rewardType: "道具",
|
||||
rewardAmount: 1,
|
||||
rewardIcon: "gift",
|
||||
isHidden: false,
|
||||
unlockRate: 34.1,
|
||||
createdAt: "2023-01-05",
|
||||
updatedAt: "2023-01-05",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "时尚达人",
|
||||
description: "收集10套服装",
|
||||
icon: "shirt",
|
||||
category: "收集",
|
||||
requirement: "收集10套不同的服装",
|
||||
rewardType: "服装",
|
||||
rewardAmount: 1,
|
||||
rewardIcon: "shirt",
|
||||
isHidden: false,
|
||||
unlockRate: 56.3,
|
||||
createdAt: "2023-01-06",
|
||||
updatedAt: "2023-01-06",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
name: "音乐鉴赏家",
|
||||
description: "收听20首不同的歌曲",
|
||||
icon: "music",
|
||||
category: "收集",
|
||||
requirement: "收听20首不同的歌曲",
|
||||
rewardType: "虚拟币",
|
||||
rewardAmount: 300,
|
||||
rewardIcon: "coins",
|
||||
isHidden: false,
|
||||
unlockRate: 42.9,
|
||||
createdAt: "2023-01-07",
|
||||
updatedAt: "2023-01-07",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
name: "舞动青春",
|
||||
description: "观看10支不同的舞蹈",
|
||||
icon: "footprints",
|
||||
category: "收集",
|
||||
requirement: "观看10支不同的舞蹈表演",
|
||||
rewardType: "经验值",
|
||||
rewardAmount: 200,
|
||||
rewardIcon: "zap",
|
||||
isHidden: false,
|
||||
unlockRate: 38.5,
|
||||
createdAt: "2023-01-08",
|
||||
updatedAt: "2023-01-08",
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
name: "美食家",
|
||||
description: "收集15种不同的食物",
|
||||
icon: "utensils",
|
||||
category: "收集",
|
||||
requirement: "收集15种不同的食物",
|
||||
rewardType: "道具",
|
||||
rewardAmount: 1,
|
||||
rewardIcon: "gift",
|
||||
isHidden: false,
|
||||
unlockRate: 27.4,
|
||||
createdAt: "2023-01-09",
|
||||
updatedAt: "2023-01-09",
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "隐藏的秘密",
|
||||
description: "发现洛天依的秘密",
|
||||
icon: "sparkles",
|
||||
category: "隐藏",
|
||||
requirement: "???",
|
||||
rewardType: "称号",
|
||||
rewardAmount: 1,
|
||||
rewardIcon: "badge",
|
||||
isHidden: true,
|
||||
unlockRate: 5.2,
|
||||
createdAt: "2023-01-10",
|
||||
updatedAt: "2023-01-10",
|
||||
},
|
||||
]
|
||||
rewardAmount: a.points || 0,
|
||||
isHidden: a.is_hidden || false,
|
||||
unlockRate: undefined,
|
||||
createdAt: a.created_at ? a.created_at.split("T")[0] : "",
|
||||
updatedAt: a.updated_at ? a.updated_at.split("T")[0] : "",
|
||||
}
|
||||
}
|
||||
|
||||
// 获取成就列表
|
||||
export const getAchievements = async (params?: PaginationParams): Promise<PaginatedResponse<Achievement>> => {
|
||||
let filteredAchievements = [...mockAchievements]
|
||||
|
||||
// 搜索过滤
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase()
|
||||
filteredAchievements = filteredAchievements.filter(
|
||||
(achievement) =>
|
||||
achievement.name.toLowerCase().includes(search) ||
|
||||
achievement.description.toLowerCase().includes(search) ||
|
||||
achievement.category.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (params?.sortBy) {
|
||||
filteredAchievements.sort((a: any, b: any) => {
|
||||
const aValue = a[params.sortBy!]
|
||||
const bValue = b[params.sortBy!]
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
|
||||
})
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = params?.page || 1
|
||||
const pageSize = params?.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const paginatedAchievements = filteredAchievements.slice(startIndex, startIndex + pageSize)
|
||||
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
|
||||
|
||||
return mockResponse({
|
||||
items: paginatedAchievements,
|
||||
total: filteredAchievements.length,
|
||||
const response = await apiClient.get(
|
||||
`/achievement/achievements/?page=${page}&page_size=${pageSize}&show_hidden=true${searchParam}`
|
||||
)
|
||||
|
||||
const data = response.data?.data || response.data
|
||||
const results: any[] = data.results || data
|
||||
const total = data.count || results.length
|
||||
|
||||
return {
|
||||
items: results.map(mapBackendAchievement),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(filteredAchievements.length / pageSize),
|
||||
})
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个成就
|
||||
export const getAchievement = async (id: string): Promise<Achievement> => {
|
||||
const achievement = mockAchievements.find((achievement) => achievement.id === id)
|
||||
|
||||
if (!achievement) {
|
||||
return mockResponse({} as Achievement, "成就不存在")
|
||||
}
|
||||
|
||||
return mockResponse(achievement)
|
||||
const response = await apiClient.get(`/achievement/achievements/${id}/`)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendAchievement(data)
|
||||
}
|
||||
|
||||
// 创建成就
|
||||
// 创建成就(需要管理员权限)
|
||||
export const createAchievement = async (achievementData: Partial<Achievement>): Promise<Achievement> => {
|
||||
const newAchievement: Achievement = {
|
||||
id: String(mockAchievements.length + 1),
|
||||
name: achievementData.name || "",
|
||||
description: achievementData.description || "",
|
||||
icon: achievementData.icon || "award",
|
||||
category: achievementData.category || "特殊",
|
||||
requirement: achievementData.requirement || "",
|
||||
rewardType: achievementData.rewardType || "经验值",
|
||||
rewardAmount: achievementData.rewardAmount || 0,
|
||||
rewardIcon: achievementData.rewardIcon,
|
||||
isHidden: achievementData.isHidden || false,
|
||||
unlockRate: 0,
|
||||
createdAt: new Date().toISOString().split("T")[0],
|
||||
updatedAt: new Date().toISOString().split("T")[0],
|
||||
const typeReverseMap: Record<string, string> = {
|
||||
互动: "activity",
|
||||
好感度: "activity",
|
||||
收集: "usage",
|
||||
探索: "activity",
|
||||
特殊: "special",
|
||||
隐藏: "hidden",
|
||||
}
|
||||
|
||||
mockAchievements.push(newAchievement)
|
||||
const payload = {
|
||||
name: achievementData.name,
|
||||
description: achievementData.description,
|
||||
achievement_type: typeReverseMap[achievementData.category || "特殊"] || "special",
|
||||
is_hidden: achievementData.isHidden || false,
|
||||
points: achievementData.rewardAmount || 0,
|
||||
rarity: "common",
|
||||
conditions: achievementData.requirement ? { description: achievementData.requirement } : {},
|
||||
}
|
||||
|
||||
return mockResponse(newAchievement)
|
||||
const response = await apiClient.post(`/achievement/achievements/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendAchievement(data)
|
||||
}
|
||||
|
||||
// 更新成就
|
||||
// 更新成就(需要管理员权限)
|
||||
export const updateAchievement = async (id: string, achievementData: Partial<Achievement>): Promise<Achievement> => {
|
||||
const achievementIndex = mockAchievements.findIndex((achievement) => achievement.id === id)
|
||||
|
||||
if (achievementIndex === -1) {
|
||||
return mockResponse({} as Achievement, "成就不存在")
|
||||
const typeReverseMap: Record<string, string> = {
|
||||
互动: "activity",
|
||||
好感度: "activity",
|
||||
收集: "usage",
|
||||
探索: "activity",
|
||||
特殊: "special",
|
||||
隐藏: "hidden",
|
||||
}
|
||||
|
||||
const updatedAchievement = {
|
||||
...mockAchievements[achievementIndex],
|
||||
...achievementData,
|
||||
updatedAt: new Date().toISOString().split("T")[0],
|
||||
}
|
||||
const payload: any = {}
|
||||
if (achievementData.name !== undefined) payload.name = achievementData.name
|
||||
if (achievementData.description !== undefined) payload.description = achievementData.description
|
||||
if (achievementData.category !== undefined) payload.achievement_type = typeReverseMap[achievementData.category] || "special"
|
||||
if (achievementData.isHidden !== undefined) payload.is_hidden = achievementData.isHidden
|
||||
if (achievementData.rewardAmount !== undefined) payload.points = achievementData.rewardAmount
|
||||
if (achievementData.requirement !== undefined) payload.conditions = { description: achievementData.requirement }
|
||||
|
||||
mockAchievements[achievementIndex] = updatedAchievement
|
||||
|
||||
return mockResponse(updatedAchievement)
|
||||
const response = await apiClient.patch(`/achievement/achievements/${id}/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendAchievement(data)
|
||||
}
|
||||
|
||||
// 删除成就
|
||||
// 删除成就(需要管理员权限)
|
||||
export const deleteAchievement = async (id: string): Promise<boolean> => {
|
||||
const achievementIndex = mockAchievements.findIndex((achievement) => achievement.id === id)
|
||||
|
||||
if (achievementIndex === -1) {
|
||||
return mockResponse(false, "成就不存在")
|
||||
}
|
||||
|
||||
mockAchievements.splice(achievementIndex, 1)
|
||||
|
||||
return mockResponse(true)
|
||||
await apiClient.delete(`/achievement/achievements/${id}/`)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -1,306 +1,138 @@
|
||||
import type { AffinityRule, AffinityLevel } from "./types"
|
||||
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
|
||||
import { apiClient } from "./client"
|
||||
import type { PaginatedResponse, PaginationParams } from "./client"
|
||||
|
||||
// 模拟好感度规则数据
|
||||
const mockAffinityRules: AffinityRule[] = [
|
||||
{
|
||||
id: "AFR001",
|
||||
name: "每日登录",
|
||||
description: "用户每日登录系统可获得好感度",
|
||||
points: 10,
|
||||
type: "日常活动",
|
||||
status: "已启用",
|
||||
},
|
||||
{
|
||||
id: "AFR002",
|
||||
name: "赠送礼物",
|
||||
description: "用户赠送礼物可获得好感度",
|
||||
points: 20,
|
||||
type: "互动活动",
|
||||
status: "已启用",
|
||||
},
|
||||
{
|
||||
id: "AFR003",
|
||||
name: "完成任务",
|
||||
description: "用户完成指定任务可获得好感度",
|
||||
points: 15,
|
||||
type: "任务活动",
|
||||
status: "已启用",
|
||||
},
|
||||
{
|
||||
id: "AFR004",
|
||||
name: "参与活动",
|
||||
description: "用户参与系统活动可获得好感度",
|
||||
points: 30,
|
||||
type: "特殊活动",
|
||||
status: "已启用",
|
||||
},
|
||||
{
|
||||
id: "AFR005",
|
||||
name: "购买道具",
|
||||
description: "用户购买道具可获得好感度",
|
||||
points: 25,
|
||||
type: "消费活动",
|
||||
status: "未启用",
|
||||
},
|
||||
]
|
||||
// 将后端 AffinityRule 数据映射到前端类型
|
||||
function mapBackendRule(r: any): AffinityRule {
|
||||
return {
|
||||
id: String(r.id),
|
||||
action: r.name,
|
||||
points: r.points || 0,
|
||||
description: r.description || "",
|
||||
dailyLimit: r.daily_limit ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟好感度等级数据
|
||||
const mockAffinityLevels: AffinityLevel[] = [
|
||||
{
|
||||
id: "AFL001",
|
||||
level: 1,
|
||||
name: "初识",
|
||||
pointsRequired: 0,
|
||||
rewards: ["基础表情包", "基础头像框"],
|
||||
description: "刚刚认识洛天依,开始了解她的世界。",
|
||||
},
|
||||
{
|
||||
id: "AFL002",
|
||||
level: 2,
|
||||
name: "相识",
|
||||
pointsRequired: 100,
|
||||
rewards: ["语音问候", "特殊表情包"],
|
||||
description: "与洛天依有了初步的交流,开始建立友谊。",
|
||||
},
|
||||
{
|
||||
id: "AFL003",
|
||||
level: 3,
|
||||
name: "熟悉",
|
||||
pointsRequired: 300,
|
||||
rewards: ["专属头像框", "房间装饰道具"],
|
||||
description: "与洛天依建立了稳定的友谊,互相了解更多。",
|
||||
},
|
||||
{
|
||||
id: "AFL004",
|
||||
level: 4,
|
||||
name: "亲密",
|
||||
pointsRequired: 600,
|
||||
rewards: ["专属服装", "专属互动动画"],
|
||||
description: "与洛天依建立了深厚的友谊,成为了亲密的朋友。",
|
||||
},
|
||||
{
|
||||
id: "AFL005",
|
||||
level: 5,
|
||||
name: "挚友",
|
||||
pointsRequired: 1000,
|
||||
rewards: ["限定服装", "专属歌曲", "特殊互动场景"],
|
||||
description: "与洛天依建立了最亲密的关系,成为了无可替代的挚友。",
|
||||
},
|
||||
]
|
||||
// 将后端 AffinityLevel 数据映射到前端类型
|
||||
function mapBackendLevel(l: any): AffinityLevel {
|
||||
return {
|
||||
id: String(l.id),
|
||||
level: l.level,
|
||||
name: l.name,
|
||||
description: l.description || "",
|
||||
requiredPoints: l.required_points,
|
||||
rewards: Array.isArray(l.rewards) ? l.rewards : [],
|
||||
}
|
||||
}
|
||||
|
||||
// ── 好感度规则 ────────────────────────────────────────────
|
||||
|
||||
// 获取好感度规则列表
|
||||
export const getAffinityRules = async (params?: PaginationParams): Promise<PaginatedResponse<AffinityRule>> => {
|
||||
let filteredRules = [...mockAffinityRules]
|
||||
|
||||
// 搜索过滤
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase()
|
||||
filteredRules = filteredRules.filter(
|
||||
(rule) =>
|
||||
rule.name.toLowerCase().includes(search) ||
|
||||
rule.description.toLowerCase().includes(search) ||
|
||||
rule.type.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (params?.sortBy) {
|
||||
filteredRules.sort((a: any, b: any) => {
|
||||
const aValue = a[params.sortBy!]
|
||||
const bValue = b[params.sortBy!]
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
|
||||
})
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = params?.page || 1
|
||||
const pageSize = params?.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const paginatedRules = filteredRules.slice(startIndex, startIndex + pageSize)
|
||||
|
||||
return mockResponse({
|
||||
items: paginatedRules,
|
||||
total: filteredRules.length,
|
||||
const response = await apiClient.get(`/user/affinity-rules/?page=${page}&page_size=${pageSize}`)
|
||||
const data = response.data?.data || response.data
|
||||
const results: any[] = data.results || (Array.isArray(data) ? data : [])
|
||||
const total = data.count || results.length
|
||||
|
||||
return {
|
||||
items: results.map(mapBackendRule),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(filteredRules.length / pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
// 获取单个好感度规则
|
||||
export const getAffinityRule = async (id: string): Promise<AffinityRule> => {
|
||||
const rule = mockAffinityRules.find((rule) => rule.id === id)
|
||||
|
||||
if (!rule) {
|
||||
return mockResponse({} as AffinityRule, "好感度规则不存在")
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
|
||||
return mockResponse(rule)
|
||||
}
|
||||
|
||||
// 创建好感度规则
|
||||
export const createAffinityRule = async (ruleData: Partial<AffinityRule>): Promise<AffinityRule> => {
|
||||
// 生成新的好感度规则ID
|
||||
const ruleId = "AFR" + String(mockAffinityRules.length + 1).padStart(3, "0")
|
||||
export const getAffinityRule = async (id: string): Promise<AffinityRule> => {
|
||||
const response = await apiClient.get(`/user/affinity-rules/${id}/`)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendRule(data)
|
||||
}
|
||||
|
||||
const newRule: AffinityRule = {
|
||||
id: ruleId,
|
||||
name: ruleData.name || "",
|
||||
export const createAffinityRule = async (ruleData: Partial<AffinityRule>): Promise<AffinityRule> => {
|
||||
const payload = {
|
||||
name: ruleData.action,
|
||||
description: ruleData.description || "",
|
||||
points: ruleData.points || 0,
|
||||
type: ruleData.type || "",
|
||||
status: ruleData.status || "未启用",
|
||||
daily_limit: ruleData.dailyLimit ?? null,
|
||||
is_active: true,
|
||||
}
|
||||
|
||||
mockAffinityRules.push(newRule)
|
||||
|
||||
return mockResponse(newRule)
|
||||
const response = await apiClient.post(`/user/affinity-rules/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendRule(data)
|
||||
}
|
||||
|
||||
// 更新好感度规则
|
||||
export const updateAffinityRule = async (id: string, ruleData: Partial<AffinityRule>): Promise<AffinityRule> => {
|
||||
const ruleIndex = mockAffinityRules.findIndex((rule) => rule.id === id)
|
||||
const payload: any = {}
|
||||
if (ruleData.action !== undefined) payload.name = ruleData.action
|
||||
if (ruleData.description !== undefined) payload.description = ruleData.description
|
||||
if (ruleData.points !== undefined) payload.points = ruleData.points
|
||||
if (ruleData.dailyLimit !== undefined) payload.daily_limit = ruleData.dailyLimit
|
||||
|
||||
if (ruleIndex === -1) {
|
||||
return mockResponse({} as AffinityRule, "好感度规则不存在")
|
||||
}
|
||||
|
||||
const updatedRule = {
|
||||
...mockAffinityRules[ruleIndex],
|
||||
...ruleData,
|
||||
}
|
||||
|
||||
mockAffinityRules[ruleIndex] = updatedRule
|
||||
|
||||
return mockResponse(updatedRule)
|
||||
const response = await apiClient.patch(`/user/affinity-rules/${id}/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendRule(data)
|
||||
}
|
||||
|
||||
// 删除好感度规则
|
||||
export const deleteAffinityRule = async (id: string): Promise<boolean> => {
|
||||
const ruleIndex = mockAffinityRules.findIndex((rule) => rule.id === id)
|
||||
|
||||
if (ruleIndex === -1) {
|
||||
return mockResponse(false, "好感度规则不存在")
|
||||
}
|
||||
|
||||
mockAffinityRules.splice(ruleIndex, 1)
|
||||
|
||||
return mockResponse(true)
|
||||
await apiClient.delete(`/user/affinity-rules/${id}/`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取好感度等级列表
|
||||
// ── 好感度等级 ────────────────────────────────────────────
|
||||
|
||||
export const getAffinityLevels = async (params?: PaginationParams): Promise<PaginatedResponse<AffinityLevel>> => {
|
||||
let filteredLevels = [...mockAffinityLevels]
|
||||
|
||||
// 搜索过滤
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase()
|
||||
filteredLevels = filteredLevels.filter(
|
||||
(level) => level.name.toLowerCase().includes(search) || level.description.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (params?.sortBy) {
|
||||
filteredLevels.sort((a: any, b: any) => {
|
||||
const aValue = a[params.sortBy!]
|
||||
const bValue = b[params.sortBy!]
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
|
||||
})
|
||||
} else {
|
||||
// 默认按等级排序
|
||||
filteredLevels.sort((a, b) => a.level - b.level)
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = params?.page || 1
|
||||
const pageSize = params?.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const paginatedLevels = filteredLevels.slice(startIndex, startIndex + pageSize)
|
||||
const pageSize = params?.pageSize || 50 // 等级数量通常较少,一次全取
|
||||
|
||||
return mockResponse({
|
||||
items: paginatedLevels,
|
||||
total: filteredLevels.length,
|
||||
const response = await apiClient.get(`/user/affinity-levels/?page=${page}&page_size=${pageSize}`)
|
||||
const data = response.data?.data || response.data
|
||||
const results: any[] = data.results || (Array.isArray(data) ? data : [])
|
||||
const total = data.count || results.length
|
||||
|
||||
return {
|
||||
items: results.map(mapBackendLevel),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(filteredLevels.length / pageSize),
|
||||
})
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个好感度等级
|
||||
export const getAffinityLevel = async (id: string): Promise<AffinityLevel> => {
|
||||
const level = mockAffinityLevels.find((level) => level.id === id)
|
||||
|
||||
if (!level) {
|
||||
return mockResponse({} as AffinityLevel, "好感度等级不存在")
|
||||
}
|
||||
|
||||
return mockResponse(level)
|
||||
const response = await apiClient.get(`/user/affinity-levels/${id}/`)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendLevel(data)
|
||||
}
|
||||
|
||||
// 创建好感度等级
|
||||
export const createAffinityLevel = async (levelData: Partial<AffinityLevel>): Promise<AffinityLevel> => {
|
||||
// 生成新的好感度等级ID
|
||||
const levelId = "AFL" + String(mockAffinityLevels.length + 1).padStart(3, "0")
|
||||
|
||||
const newLevel: AffinityLevel = {
|
||||
id: levelId,
|
||||
level: levelData.level || mockAffinityLevels.length + 1,
|
||||
name: levelData.name || "",
|
||||
pointsRequired: levelData.pointsRequired || 0,
|
||||
rewards: levelData.rewards || [],
|
||||
const payload = {
|
||||
level: levelData.level,
|
||||
name: levelData.name,
|
||||
description: levelData.description || "",
|
||||
required_points: levelData.requiredPoints || 0,
|
||||
rewards: levelData.rewards || [],
|
||||
}
|
||||
|
||||
mockAffinityLevels.push(newLevel)
|
||||
|
||||
// 按等级排序
|
||||
mockAffinityLevels.sort((a, b) => a.level - b.level)
|
||||
|
||||
return mockResponse(newLevel)
|
||||
const response = await apiClient.post(`/user/affinity-levels/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendLevel(data)
|
||||
}
|
||||
|
||||
// 更新好感度等级
|
||||
export const updateAffinityLevel = async (id: string, levelData: Partial<AffinityLevel>): Promise<AffinityLevel> => {
|
||||
const levelIndex = mockAffinityLevels.findIndex((level) => level.id === id)
|
||||
const payload: any = {}
|
||||
if (levelData.name !== undefined) payload.name = levelData.name
|
||||
if (levelData.description !== undefined) payload.description = levelData.description
|
||||
if (levelData.requiredPoints !== undefined) payload.required_points = levelData.requiredPoints
|
||||
if (levelData.rewards !== undefined) payload.rewards = levelData.rewards
|
||||
|
||||
if (levelIndex === -1) {
|
||||
return mockResponse({} as AffinityLevel, "好感度等级不存在")
|
||||
}
|
||||
|
||||
const updatedLevel = {
|
||||
...mockAffinityLevels[levelIndex],
|
||||
...levelData,
|
||||
}
|
||||
|
||||
mockAffinityLevels[levelIndex] = updatedLevel
|
||||
|
||||
// 按等级排序
|
||||
mockAffinityLevels.sort((a, b) => a.level - b.level)
|
||||
|
||||
return mockResponse(updatedLevel)
|
||||
const response = await apiClient.patch(`/user/affinity-levels/${id}/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendLevel(data)
|
||||
}
|
||||
|
||||
// 删除好感度等级
|
||||
export const deleteAffinityLevel = async (id: string): Promise<boolean> => {
|
||||
const levelIndex = mockAffinityLevels.findIndex((level) => level.id === id)
|
||||
|
||||
if (levelIndex === -1) {
|
||||
return mockResponse(false, "好感度等级不存在")
|
||||
}
|
||||
|
||||
mockAffinityLevels.splice(levelIndex, 1)
|
||||
|
||||
return mockResponse(true)
|
||||
await apiClient.delete(`/user/affinity-levels/${id}/`)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -1,185 +1,84 @@
|
||||
import type { AiModel } from "./types"
|
||||
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
|
||||
import { apiClient } from "./client"
|
||||
import type { PaginatedResponse, PaginationParams } from "./client"
|
||||
|
||||
// 模拟AI模型数据
|
||||
const mockAiModels: AiModel[] = [
|
||||
{
|
||||
id: "AI001",
|
||||
name: "洛天依语音合成模型V1",
|
||||
// 将后端 Bot 数据映射到前端 AiModel 类型
|
||||
// 后端 Bot 只有 id, name, description;其他字段用默认值
|
||||
function mapBackendBot(b: any): AiModel {
|
||||
return {
|
||||
id: String(b.id),
|
||||
name: b.name,
|
||||
version: "1.0.0",
|
||||
type: "语音合成",
|
||||
status: "已发布",
|
||||
createdAt: "2023-01-15",
|
||||
updatedAt: "2023-01-15",
|
||||
description: "洛天依的基础语音合成模型,支持基本的歌声合成功能。",
|
||||
},
|
||||
{
|
||||
id: "AI002",
|
||||
name: "洛天依对话模型V1",
|
||||
version: "1.0.0",
|
||||
type: "对话模型",
|
||||
status: "已发布",
|
||||
createdAt: "2023-03-20",
|
||||
updatedAt: "2023-03-20",
|
||||
description: "洛天依的基础对话模型,支持简单的问答交互。",
|
||||
},
|
||||
{
|
||||
id: "AI003",
|
||||
name: "洛天依语音合成模型V2",
|
||||
version: "2.0.0",
|
||||
type: "语音合成",
|
||||
status: "已发布",
|
||||
createdAt: "2023-06-10",
|
||||
updatedAt: "2023-06-10",
|
||||
description: "洛天依的进阶语音合成模型,支持更自然的歌声合成和情感表达。",
|
||||
},
|
||||
{
|
||||
id: "AI004",
|
||||
name: "洛天依对话模型V2",
|
||||
version: "2.0.0",
|
||||
type: "对话模型",
|
||||
status: "已发布",
|
||||
createdAt: "2023-09-05",
|
||||
updatedAt: "2023-09-05",
|
||||
description: "洛天依的进阶对话模型,支持更复杂的对话和情感理解。",
|
||||
},
|
||||
{
|
||||
id: "AI005",
|
||||
name: "洛天依多模态模型V1",
|
||||
version: "1.0.0",
|
||||
type: "多模态模型",
|
||||
status: "开发中",
|
||||
createdAt: "2023-12-01",
|
||||
updatedAt: "2023-12-01",
|
||||
description: "洛天依的多模态模型,支持图像识别和生成,以及与语音、文本的交互。",
|
||||
},
|
||||
]
|
||||
description: b.description || "",
|
||||
status: "活跃",
|
||||
parameters: undefined,
|
||||
accuracy: undefined,
|
||||
trainingData: undefined,
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 获取AI模型列表
|
||||
// 获取AI模型(Bot)列表
|
||||
export const getAiModels = async (params?: PaginationParams): Promise<PaginatedResponse<AiModel>> => {
|
||||
let filteredModels = [...mockAiModels]
|
||||
|
||||
// 搜索过滤
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase()
|
||||
filteredModels = filteredModels.filter(
|
||||
(model) =>
|
||||
model.name.toLowerCase().includes(search) ||
|
||||
model.id.toLowerCase().includes(search) ||
|
||||
model.type.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (params?.sortBy) {
|
||||
filteredModels.sort((a: any, b: any) => {
|
||||
const aValue = a[params.sortBy!]
|
||||
const bValue = b[params.sortBy!]
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
|
||||
})
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = params?.page || 1
|
||||
const pageSize = params?.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize)
|
||||
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
|
||||
|
||||
return mockResponse({
|
||||
items: paginatedModels,
|
||||
total: filteredModels.length,
|
||||
const response = await apiClient.get(
|
||||
`/ai/bots/?page=${page}&page_size=${pageSize}${searchParam}`
|
||||
)
|
||||
|
||||
const data = response.data?.data || response.data
|
||||
const results: any[] = data.results || (Array.isArray(data) ? data : [data])
|
||||
const total = data.count || results.length
|
||||
|
||||
return {
|
||||
items: results.map(mapBackendBot),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(filteredModels.length / pageSize),
|
||||
})
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个AI模型
|
||||
export const getAiModel = async (id: string): Promise<AiModel> => {
|
||||
const model = mockAiModels.find((model) => model.id === id)
|
||||
|
||||
if (!model) {
|
||||
return mockResponse({} as AiModel, "AI模型不存在")
|
||||
}
|
||||
|
||||
return mockResponse(model)
|
||||
const response = await apiClient.get(`/ai/bots/${id}/`)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendBot(data)
|
||||
}
|
||||
|
||||
// 创建AI模型
|
||||
// 创建AI模型(需要管理员权限)
|
||||
export const createAiModel = async (modelData: Partial<AiModel>): Promise<AiModel> => {
|
||||
// 生成新的AI模型ID
|
||||
const modelId = "AI" + String(mockAiModels.length + 1).padStart(3, "0")
|
||||
|
||||
const now = new Date().toISOString().split("T")[0]
|
||||
|
||||
const newModel: AiModel = {
|
||||
id: modelId,
|
||||
name: modelData.name || "",
|
||||
version: modelData.version || "1.0.0",
|
||||
type: modelData.type || "",
|
||||
status: modelData.status || "开发中",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
const payload = {
|
||||
name: modelData.name,
|
||||
description: modelData.description || "",
|
||||
}
|
||||
|
||||
mockAiModels.push(newModel)
|
||||
|
||||
return mockResponse(newModel)
|
||||
const response = await apiClient.post(`/ai/bots/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendBot(data)
|
||||
}
|
||||
|
||||
// 更新AI模型
|
||||
// 更新AI模型(需要管理员权限)
|
||||
export const updateAiModel = async (id: string, modelData: Partial<AiModel>): Promise<AiModel> => {
|
||||
const modelIndex = mockAiModels.findIndex((model) => model.id === id)
|
||||
const payload: any = {}
|
||||
if (modelData.name !== undefined) payload.name = modelData.name
|
||||
if (modelData.description !== undefined) payload.description = modelData.description
|
||||
|
||||
if (modelIndex === -1) {
|
||||
return mockResponse({} as AiModel, "AI模型不存在")
|
||||
}
|
||||
|
||||
const updatedModel = {
|
||||
...mockAiModels[modelIndex],
|
||||
...modelData,
|
||||
updatedAt: new Date().toISOString().split("T")[0],
|
||||
}
|
||||
|
||||
mockAiModels[modelIndex] = updatedModel
|
||||
|
||||
return mockResponse(updatedModel)
|
||||
const response = await apiClient.patch(`/ai/bots/${id}/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendBot(data)
|
||||
}
|
||||
|
||||
// 删除AI模型
|
||||
// 删除AI模型(需要管理员权限)
|
||||
export const deleteAiModel = async (id: string): Promise<boolean> => {
|
||||
const modelIndex = mockAiModels.findIndex((model) => model.id === id)
|
||||
|
||||
if (modelIndex === -1) {
|
||||
return mockResponse(false, "AI模型不存在")
|
||||
}
|
||||
|
||||
// 已发布的AI模型不能删除
|
||||
if (mockAiModels[modelIndex].status === "已发布") {
|
||||
return mockResponse(false, "已发布的AI模型不能删除")
|
||||
}
|
||||
|
||||
mockAiModels.splice(modelIndex, 1)
|
||||
|
||||
return mockResponse(true)
|
||||
await apiClient.delete(`/ai/bots/${id}/`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 发布AI模型
|
||||
// 发布AI模型(Bot没有发布概念,这里直接返回当前数据)
|
||||
export const publishAiModel = async (id: string): Promise<AiModel> => {
|
||||
const modelIndex = mockAiModels.findIndex((model) => model.id === id)
|
||||
|
||||
if (modelIndex === -1) {
|
||||
return mockResponse({} as AiModel, "AI模型不存在")
|
||||
}
|
||||
|
||||
mockAiModels[modelIndex].status = "已发布"
|
||||
mockAiModels[modelIndex].updatedAt = new Date().toISOString().split("T")[0]
|
||||
|
||||
return mockResponse(mockAiModels[modelIndex])
|
||||
return getAiModel(id)
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ export interface EmailLoginResponse {
|
||||
code: number;
|
||||
data: {
|
||||
token: string;
|
||||
is_superuser?: boolean;
|
||||
role?: string;
|
||||
};
|
||||
message: string;
|
||||
}
|
||||
@ -42,15 +44,34 @@ export const emailLogin = async (email: string, password: string): Promise<Email
|
||||
* 保存登录凭证到本地存储和Cookie
|
||||
* @param token 访问令牌
|
||||
*/
|
||||
export const saveAuthToken = (token: string): void => {
|
||||
export const saveAuthToken = (token: string, isSuperUser?: boolean, role?: string): void => {
|
||||
// 保存到localStorage
|
||||
localStorage.setItem("auth_token", token);
|
||||
|
||||
|
||||
// 保存超级管理员标识
|
||||
if (isSuperUser !== undefined) {
|
||||
localStorage.setItem("is_superuser", isSuperUser ? "true" : "false");
|
||||
}
|
||||
|
||||
// 保存用户角色
|
||||
if (role) {
|
||||
localStorage.setItem("user_role", role);
|
||||
}
|
||||
|
||||
// 保存到Cookie,以便middleware可以访问
|
||||
// 过期时间设为7天
|
||||
Cookies.set("auth_token", token, { expires: 7, path: '/' });
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查当前用户是否为超级管理员
|
||||
* @returns 是否为超级管理员
|
||||
*/
|
||||
export const isSuperUser = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem("is_superuser") === "true";
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取存储的登录凭证
|
||||
* @returns 登录凭证
|
||||
@ -67,6 +88,8 @@ export const getAuthToken = (): string | null => {
|
||||
*/
|
||||
export const clearAuthToken = (): void => {
|
||||
localStorage.removeItem("auth_token");
|
||||
localStorage.removeItem("is_superuser");
|
||||
localStorage.removeItem("user_role");
|
||||
Cookies.remove("auth_token", { path: '/' });
|
||||
};
|
||||
|
||||
|
||||
@ -50,14 +50,14 @@ apiClient.interceptors.response.use(
|
||||
(error) => {
|
||||
console.error('❌ 响应错误:', error.response?.status, error.config?.url);
|
||||
|
||||
// 处理401未授权错误
|
||||
// 处理401未授权错误(token过期或无效)
|
||||
if (error.response?.status === 401) {
|
||||
console.warn('🚫 认证失败,token可能过期或无效');
|
||||
console.log('响应详情:', error.response?.data);
|
||||
// 暂时注释掉自动清除,方便调试
|
||||
// localStorage.removeItem('auth_token');
|
||||
// 可以选择重定向到登录页
|
||||
// window.location.href = '/login';
|
||||
localStorage.removeItem('auth_token');
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
|
||||
@ -1,193 +1,46 @@
|
||||
import type { Dance } from "./types"
|
||||
import { apiClient } from "./client"
|
||||
import { handleApiError } from "./error-handler"
|
||||
|
||||
// 模拟舞蹈数据
|
||||
const mockDances: Dance[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "千本樱",
|
||||
choreographer: "洛天依工作室",
|
||||
duration: "3:45",
|
||||
difficulty: "中等",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "基于《千本樱》歌曲的经典舞蹈编排,动作流畅优美,适合中等水平的舞者。",
|
||||
motionFile: "senbonzakura_motion.fbx",
|
||||
category: "日式",
|
||||
tags: ["经典", "流行", "日式"],
|
||||
createdAt: "2023-01-15T08:30:00Z",
|
||||
updatedAt: "2023-02-20T14:15:00Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "权御天下",
|
||||
choreographer: "洛天依动作组",
|
||||
duration: "4:20",
|
||||
difficulty: "高级",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "中国风舞蹈,动作幅度大,需要较高的舞蹈基础,展现古风韵味。",
|
||||
motionFile: "quanyutianxia_motion.fbx",
|
||||
category: "中国风",
|
||||
tags: ["高难度", "中国风", "古风"],
|
||||
createdAt: "2023-03-10T10:45:00Z",
|
||||
updatedAt: "2023-04-05T16:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "达拉崩吧",
|
||||
choreographer: "洛天依舞蹈工作室",
|
||||
duration: "3:10",
|
||||
difficulty: "初级",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "轻松欢快的舞蹈,适合初学者,动作简单易学。",
|
||||
motionFile: "dalabengba_motion.fbx",
|
||||
category: "流行",
|
||||
tags: ["简单", "欢快", "流行"],
|
||||
createdAt: "2023-05-20T09:15:00Z",
|
||||
updatedAt: "2023-05-25T11:20:00Z",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "普通DISCO",
|
||||
choreographer: "洛天依动作设计组",
|
||||
duration: "3:30",
|
||||
difficulty: "中等",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "现代disco风格舞蹈,节奏感强,动作活力四射。",
|
||||
motionFile: "disco_motion.fbx",
|
||||
category: "现代",
|
||||
tags: ["活力", "现代", "disco"],
|
||||
createdAt: "2023-06-05T14:30:00Z",
|
||||
updatedAt: "2023-06-10T17:45:00Z",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "华灯宴",
|
||||
choreographer: "古风舞蹈团队",
|
||||
duration: "4:50",
|
||||
difficulty: "高级",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "古风舞蹈,动作优美细腻,需要较高的舞蹈技巧和表现力。",
|
||||
motionFile: "hualangyan_motion.fbx",
|
||||
category: "中国风",
|
||||
tags: ["古风", "优美", "高难度"],
|
||||
createdAt: "2023-07-12T11:20:00Z",
|
||||
updatedAt: "2023-07-18T13:40:00Z",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "炉心融解",
|
||||
choreographer: "日系舞蹈组",
|
||||
duration: "3:55",
|
||||
difficulty: "中高级",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "日系风格舞蹈,动作精准,需要良好的节奏感和协调性。",
|
||||
motionFile: "meltdown_motion.fbx",
|
||||
category: "日式",
|
||||
tags: ["日系", "精准", "流行"],
|
||||
createdAt: "2023-08-03T16:10:00Z",
|
||||
updatedAt: "2023-08-10T09:25:00Z",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
name: "芒种",
|
||||
choreographer: "中国舞蹈团",
|
||||
duration: "4:05",
|
||||
difficulty: "中等",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "中国传统节气主题舞蹈,动作舒缓优美,富有诗意。",
|
||||
motionFile: "mangzhong_motion.fbx",
|
||||
category: "中国风",
|
||||
tags: ["传统", "优美", "诗意"],
|
||||
createdAt: "2023-09-15T10:30:00Z",
|
||||
updatedAt: "2023-09-20T14:15:00Z",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
name: "寄明月",
|
||||
choreographer: "古风舞蹈设计师",
|
||||
duration: "4:30",
|
||||
difficulty: "高级",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "古风舞蹈,动作优美流畅,需要较高的舞蹈功底和表现力。",
|
||||
motionFile: "jimingyue_motion.fbx",
|
||||
category: "中国风",
|
||||
tags: ["古风", "优美", "高难度"],
|
||||
createdAt: "2023-10-08T13:45:00Z",
|
||||
updatedAt: "2023-10-15T11:20:00Z",
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
name: "极乐净土",
|
||||
choreographer: "日系动作组",
|
||||
duration: "3:40",
|
||||
difficulty: "中等",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "日系风格舞蹈,动作活泼可爱,节奏感强。",
|
||||
motionFile: "jiletutu_motion.fbx",
|
||||
category: "日式",
|
||||
tags: ["日系", "活泼", "流行"],
|
||||
createdAt: "2023-11-05T15:30:00Z",
|
||||
updatedAt: "2023-11-12T10:45:00Z",
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "牵丝戏",
|
||||
choreographer: "中国古典舞团",
|
||||
duration: "4:15",
|
||||
difficulty: "高级",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "中国古典舞蹈,动作细腻优美,需要较高的舞蹈技巧和表现力。",
|
||||
motionFile: "qiansixi_motion.fbx",
|
||||
category: "中国风",
|
||||
tags: ["古典", "优美", "高难度"],
|
||||
createdAt: "2023-12-10T09:20:00Z",
|
||||
updatedAt: "2023-12-18T14:35:00Z",
|
||||
},
|
||||
]
|
||||
// 将后端 CardTemplate(dance) 数据映射到前端 Dance 类型
|
||||
function mapBackendDance(item: any): Dance {
|
||||
const attrs = item.attributes || {}
|
||||
return {
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
choreographer: attrs.choreographer || "",
|
||||
duration: attrs.duration || "",
|
||||
difficulty: attrs.difficulty || "",
|
||||
videoUrl: attrs.tutorial_video || item.model_url || "",
|
||||
coverUrl: item.image_url || "/placeholder.svg?height=300&width=400",
|
||||
description: item.description || "",
|
||||
motionFile: item.model_url || "",
|
||||
category: attrs.style || "",
|
||||
tags: [],
|
||||
createdAt: item.created_at || "",
|
||||
updatedAt: item.updated_at || "",
|
||||
}
|
||||
}
|
||||
|
||||
// 获取舞蹈列表
|
||||
export async function getDances(page = 1, limit = 10, search = "") {
|
||||
try {
|
||||
// 模拟API请求延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
const searchParam = search ? `&search=${encodeURIComponent(search)}` : ""
|
||||
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 totalItems = filteredDances.length
|
||||
const totalPages = Math.ceil(totalItems / limit)
|
||||
const startIndex = (page - 1) * limit
|
||||
const paginatedDances = filteredDances.slice(startIndex, startIndex + limit)
|
||||
const data = response.data?.data || response.data
|
||||
const results: any[] = data.results || data
|
||||
const total = data.count || results.length
|
||||
|
||||
return {
|
||||
data: paginatedDances,
|
||||
data: results.map(mapBackendDance),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalItems,
|
||||
totalPages,
|
||||
totalItems: total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
@ -198,90 +51,70 @@ export async function getDances(page = 1, limit = 10, search = "") {
|
||||
// 获取单个舞蹈详情
|
||||
export async function getDance(id: string) {
|
||||
try {
|
||||
// 模拟API请求延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
const dance = mockDances.find((dance) => dance.id === id)
|
||||
|
||||
if (!dance) {
|
||||
throw new Error("舞蹈不存在")
|
||||
}
|
||||
|
||||
return { data: dance }
|
||||
const response = await apiClient.get(`/card/templates/${id}/`)
|
||||
const data = response.data?.data || response.data
|
||||
return { data: mapBackendDance(data) }
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新舞蹈
|
||||
// 创建新舞蹈(需要管理员权限)
|
||||
export async function createDance(danceData: Omit<Dance, "id" | "createdAt" | "updatedAt">) {
|
||||
try {
|
||||
// 模拟API请求延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 700))
|
||||
|
||||
const newDance: Dance = {
|
||||
id: `${mockDances.length + 1}`,
|
||||
...danceData,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
const payload: any = {
|
||||
name: danceData.name,
|
||||
category: "dance",
|
||||
description: danceData.description || "",
|
||||
dance_attributes: {
|
||||
choreographer: danceData.choreographer || "",
|
||||
duration: danceData.duration || "",
|
||||
difficulty: danceData.difficulty || "",
|
||||
tutorial_video: danceData.videoUrl || "",
|
||||
style: danceData.category || "",
|
||||
},
|
||||
}
|
||||
if (danceData.coverUrl && !danceData.coverUrl.includes("placeholder")) {
|
||||
payload.image_url = danceData.coverUrl
|
||||
}
|
||||
|
||||
// 在实际应用中,这里会调用API将数据保存到数据库
|
||||
// 这里我们只是模拟添加到本地数组
|
||||
mockDances.push(newDance)
|
||||
|
||||
return { data: newDance }
|
||||
const response = await apiClient.post(`/card/templates/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return { data: mapBackendDance(data) }
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新舞蹈
|
||||
// 更新舞蹈(需要管理员权限)
|
||||
export async function updateDance(id: string, danceData: Partial<Dance>) {
|
||||
try {
|
||||
// 模拟API请求延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
const payload: any = {}
|
||||
if (danceData.name !== undefined) payload.name = danceData.name
|
||||
if (danceData.description !== undefined) payload.description = danceData.description
|
||||
|
||||
const index = mockDances.findIndex((dance) => dance.id === id)
|
||||
const attrs: any = {}
|
||||
if (danceData.choreographer !== undefined) attrs.choreographer = danceData.choreographer
|
||||
if (danceData.duration !== undefined) attrs.duration = danceData.duration
|
||||
if (danceData.difficulty !== undefined) attrs.difficulty = danceData.difficulty
|
||||
if (danceData.videoUrl !== undefined) attrs.tutorial_video = danceData.videoUrl
|
||||
if (danceData.category !== undefined) attrs.style = danceData.category
|
||||
if (Object.keys(attrs).length > 0) payload.dance_attributes = attrs
|
||||
if (danceData.coverUrl !== undefined) payload.image_url = danceData.coverUrl
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error("舞蹈不存在")
|
||||
}
|
||||
|
||||
// 更新舞蹈数据
|
||||
const updatedDance = {
|
||||
...mockDances[index],
|
||||
...danceData,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// 在实际应用中,这里会调用API将更新保存到数据库
|
||||
// 这里我们只是模拟更新本地数组
|
||||
mockDances[index] = updatedDance
|
||||
|
||||
return { data: updatedDance }
|
||||
const response = await apiClient.patch(`/card/templates/${id}/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return { data: mapBackendDance(data) }
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除舞蹈
|
||||
// 删除舞蹈(需要管理员权限)
|
||||
export async function deleteDance(id: string) {
|
||||
try {
|
||||
// 模拟API请求延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
const index = mockDances.findIndex((dance) => dance.id === id)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error("舞蹈不存在")
|
||||
}
|
||||
|
||||
// 在实际应用中,这里会调用API从数据库中删除
|
||||
// 这里我们只是模拟从本地数组中删除
|
||||
const deletedDance = mockDances.splice(index, 1)[0]
|
||||
|
||||
return { data: deletedDance }
|
||||
await apiClient.delete(`/card/templates/${id}/`)
|
||||
return { data: { id } }
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,188 +1,103 @@
|
||||
import type { HomeDecor } from "./types"
|
||||
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
|
||||
import { apiClient } from "./client"
|
||||
import type { PaginatedResponse, PaginationParams } from "./client"
|
||||
|
||||
// 模拟家居装饰数据
|
||||
const mockHomeDecors: HomeDecor[] = [
|
||||
{
|
||||
id: "DEC001",
|
||||
name: "星空投影灯",
|
||||
type: "灯饰",
|
||||
rarity: "稀有",
|
||||
description: "可以在房间内投影出美丽的星空,营造浪漫氛围。",
|
||||
releaseDate: "2023-10-20",
|
||||
status: "已发布",
|
||||
activatedCount: 1342,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "DEC002",
|
||||
name: "音乐主题壁纸",
|
||||
type: "墙饰",
|
||||
rarity: "普通",
|
||||
description: "以音乐元素为主题的壁纸,适合洛天依的房间装饰。",
|
||||
releaseDate: "2023-11-05",
|
||||
status: "已发布",
|
||||
activatedCount: 2156,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "DEC003",
|
||||
name: "音符地毯",
|
||||
type: "地饰",
|
||||
rarity: "稀有",
|
||||
description: "音符形状的地毯,踩上去会发出悦耳的音符声。",
|
||||
releaseDate: "2023-12-15",
|
||||
status: "已发布",
|
||||
activatedCount: 987,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "DEC004",
|
||||
name: "全息投影装置",
|
||||
type: "科技装饰",
|
||||
rarity: "传说",
|
||||
description: "可以投影出洛天依的全息影像,实现虚拟互动。",
|
||||
releaseDate: "2024-01-20",
|
||||
status: "已发布",
|
||||
activatedCount: 456,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "DEC005",
|
||||
name: "樱花主题家具套装",
|
||||
type: "家具套装",
|
||||
rarity: "史诗",
|
||||
description: "以樱花为主题的家具套装,包含床、桌椅、柜子等多件家具。",
|
||||
releaseDate: "",
|
||||
status: "未发布",
|
||||
activatedCount: 0,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
]
|
||||
function mapBackendHomeDecor(item: any): HomeDecor {
|
||||
const attrs = item.attributes || {}
|
||||
return {
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
description: item.description || "",
|
||||
imageUrl: item.image_url || "",
|
||||
rarity: item.rarity_display || item.rarity || "",
|
||||
category: attrs.decoration_type || attrs.style || "",
|
||||
status: item.status_display || item.status || "",
|
||||
publishedAt: item.published_at || "",
|
||||
batchesCount: item.batches_count || 0,
|
||||
activeCardsCount: item.active_cards_count || 0,
|
||||
tags: attrs.placement ? [attrs.placement] : [],
|
||||
createdAt: item.created_at || "",
|
||||
updatedAt: item.updated_at || "",
|
||||
}
|
||||
}
|
||||
|
||||
// 获取家居装饰列表
|
||||
export const getHomeDecors = async (params?: PaginationParams): Promise<PaginatedResponse<HomeDecor>> => {
|
||||
let filteredDecors = [...mockHomeDecors]
|
||||
|
||||
// 搜索过滤
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase()
|
||||
filteredDecors = filteredDecors.filter(
|
||||
(decor) =>
|
||||
decor.name.toLowerCase().includes(search) ||
|
||||
decor.id.toLowerCase().includes(search) ||
|
||||
decor.type.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (params?.sortBy) {
|
||||
filteredDecors.sort((a: any, b: any) => {
|
||||
const aValue = a[params.sortBy!]
|
||||
const bValue = b[params.sortBy!]
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
|
||||
})
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = params?.page || 1
|
||||
const pageSize = params?.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const paginatedDecors = filteredDecors.slice(startIndex, startIndex + pageSize)
|
||||
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
|
||||
|
||||
return mockResponse({
|
||||
items: paginatedDecors,
|
||||
total: filteredDecors.length,
|
||||
const response = await apiClient.get(
|
||||
`/card/category/decoration/?page=${page}&page_size=${pageSize}${searchParam}`
|
||||
)
|
||||
|
||||
const data = response.data?.data || response.data
|
||||
const results: any[] = data.results || data
|
||||
const total = data.count || results.length
|
||||
|
||||
return {
|
||||
items: results.map(mapBackendHomeDecor),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(filteredDecors.length / pageSize),
|
||||
})
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个家居装饰
|
||||
export const getHomeDecor = async (id: string): Promise<HomeDecor> => {
|
||||
const decor = mockHomeDecors.find((decor) => decor.id === id)
|
||||
|
||||
if (!decor) {
|
||||
return mockResponse({} as HomeDecor, "家居装饰不存在")
|
||||
}
|
||||
|
||||
return mockResponse(decor)
|
||||
const response = await apiClient.get(`/card/templates/${id}/`)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendHomeDecor(data)
|
||||
}
|
||||
|
||||
// 创建家居装饰
|
||||
export const createHomeDecor = async (decorData: Partial<HomeDecor>): Promise<HomeDecor> => {
|
||||
// 生成新的家居装饰ID
|
||||
const decorId = "DEC" + String(mockHomeDecors.length + 1).padStart(3, "0")
|
||||
|
||||
const newDecor: HomeDecor = {
|
||||
id: decorId,
|
||||
name: decorData.name || "",
|
||||
type: decorData.type || "",
|
||||
rarity: decorData.rarity || "",
|
||||
export const createHomeDecor = async (decorData: Partial<HomeDecor> & { rarityValue?: string }): Promise<HomeDecor> => {
|
||||
const payload: any = {
|
||||
name: decorData.name,
|
||||
category: "decoration",
|
||||
description: decorData.description || "",
|
||||
releaseDate: decorData.releaseDate || "",
|
||||
status: decorData.status || "未发布",
|
||||
activatedCount: 0,
|
||||
image: decorData.image || "/placeholder.svg?height=300&width=300",
|
||||
}
|
||||
if (decorData.rarityValue) {
|
||||
payload.rarity = decorData.rarityValue
|
||||
}
|
||||
if (decorData.imageUrl) {
|
||||
payload.image_url = decorData.imageUrl
|
||||
}
|
||||
if (decorData.category) {
|
||||
payload.decoration_attributes = {
|
||||
decoration_type: decorData.category,
|
||||
}
|
||||
}
|
||||
|
||||
mockHomeDecors.push(newDecor)
|
||||
|
||||
return mockResponse(newDecor)
|
||||
const response = await apiClient.post(`/card/templates/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendHomeDecor(data)
|
||||
}
|
||||
|
||||
// 更新家居装饰
|
||||
export const updateHomeDecor = async (id: string, decorData: Partial<HomeDecor>): Promise<HomeDecor> => {
|
||||
const decorIndex = mockHomeDecors.findIndex((decor) => decor.id === id)
|
||||
export const updateHomeDecor = async (id: string, decorData: Partial<HomeDecor> & { rarityValue?: string }): Promise<HomeDecor> => {
|
||||
const payload: any = {}
|
||||
if (decorData.name !== undefined) payload.name = decorData.name
|
||||
if (decorData.description !== undefined) payload.description = decorData.description
|
||||
if (decorData.category !== undefined) payload.decoration_attributes = { decoration_type: decorData.category }
|
||||
if (decorData.rarityValue) payload.rarity = decorData.rarityValue
|
||||
if (decorData.imageUrl !== undefined) payload.image_url = decorData.imageUrl
|
||||
|
||||
if (decorIndex === -1) {
|
||||
return mockResponse({} as HomeDecor, "家居装饰不存在")
|
||||
}
|
||||
|
||||
const updatedDecor = {
|
||||
...mockHomeDecors[decorIndex],
|
||||
...decorData,
|
||||
}
|
||||
|
||||
mockHomeDecors[decorIndex] = updatedDecor
|
||||
|
||||
return mockResponse(updatedDecor)
|
||||
const response = await apiClient.patch(`/card/templates/${id}/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendHomeDecor(data)
|
||||
}
|
||||
|
||||
// 删除家居装饰
|
||||
export const deleteHomeDecor = async (id: string): Promise<boolean> => {
|
||||
const decorIndex = mockHomeDecors.findIndex((decor) => decor.id === id)
|
||||
|
||||
if (decorIndex === -1) {
|
||||
return mockResponse(false, "家居装饰不存在")
|
||||
}
|
||||
|
||||
// 已发布的家居装饰不能删除
|
||||
if (mockHomeDecors[decorIndex].status === "已发布") {
|
||||
return mockResponse(false, "已发布的家居装饰不能删除")
|
||||
}
|
||||
|
||||
mockHomeDecors.splice(decorIndex, 1)
|
||||
|
||||
return mockResponse(true)
|
||||
await apiClient.delete(`/card/templates/${id}/`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 发布家居装饰
|
||||
export const publishHomeDecor = async (id: string): Promise<HomeDecor> => {
|
||||
const decorIndex = mockHomeDecors.findIndex((decor) => decor.id === id)
|
||||
|
||||
if (decorIndex === -1) {
|
||||
return mockResponse({} as HomeDecor, "家居装饰不存在")
|
||||
}
|
||||
|
||||
mockHomeDecors[decorIndex].status = "已发布"
|
||||
mockHomeDecors[decorIndex].releaseDate = new Date().toISOString().split("T")[0]
|
||||
|
||||
return mockResponse(mockHomeDecors[decorIndex])
|
||||
const response = await apiClient.post(`/card/templates/${id}/publish/`)
|
||||
const data = response.data?.template || response.data
|
||||
return mapBackendHomeDecor(data)
|
||||
}
|
||||
|
||||
export const archiveHomeDecor = async (id: string): Promise<HomeDecor> => {
|
||||
const response = await apiClient.post(`/card/templates/${id}/archive/`)
|
||||
const data = response.data?.template || response.data
|
||||
return mapBackendHomeDecor(data)
|
||||
}
|
||||
|
||||
@ -1,189 +1,103 @@
|
||||
import type { Outfit } from "./types"
|
||||
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
|
||||
import { apiClient } from "./client"
|
||||
import type { PaginatedResponse, PaginationParams } from "./client"
|
||||
|
||||
// 模拟服装数据
|
||||
const mockOutfits: Outfit[] = [
|
||||
{
|
||||
id: "OUT001",
|
||||
name: "星空礼裙",
|
||||
designer: "星辰设计工作室",
|
||||
description: "以星空为灵感设计的华丽礼裙,适合正式场合穿着。",
|
||||
releaseDate: "2023-08-15",
|
||||
status: "已发布",
|
||||
type: "礼服",
|
||||
rarity: "传说",
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "OUT002",
|
||||
name: "音符连衣裙",
|
||||
designer: "音乐之声工作室",
|
||||
description: "装饰有音符图案的连衣裙,轻盈飘逸,适合日常穿着。",
|
||||
releaseDate: "2023-09-20",
|
||||
status: "已发布",
|
||||
type: "连衣裙",
|
||||
rarity: "稀有",
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "OUT003",
|
||||
name: "未来科技套装",
|
||||
designer: "未来主义设计团队",
|
||||
description: "融合未来科技元素的套装,包含上衣、裤子和配饰。",
|
||||
releaseDate: "2023-10-30",
|
||||
status: "已发布",
|
||||
type: "套装",
|
||||
rarity: "史诗",
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "OUT004",
|
||||
name: "传统汉服",
|
||||
designer: "古风设计工作室",
|
||||
description: "传统风格的汉服,展现中国古典美学。",
|
||||
releaseDate: "2023-12-05",
|
||||
status: "已发布",
|
||||
type: "汉服",
|
||||
rarity: "稀有",
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "OUT005",
|
||||
name: "春节限定旗袍",
|
||||
designer: "洛天依官方设计团队",
|
||||
description: "春节限定款旗袍,融合传统与现代元素。",
|
||||
releaseDate: "",
|
||||
status: "未发布",
|
||||
type: "旗袍",
|
||||
rarity: "传说",
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
]
|
||||
function mapBackendOutfit(item: any): Outfit {
|
||||
const attrs = item.attributes || {}
|
||||
return {
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
description: item.description || "",
|
||||
imageUrl: item.image_url || "",
|
||||
rarity: item.rarity_display || item.rarity || "",
|
||||
category: attrs.style || item.card_type_display || "",
|
||||
status: item.status_display || item.status || "",
|
||||
publishedAt: item.published_at || "",
|
||||
batchesCount: item.batches_count || 0,
|
||||
activeCardsCount: item.active_cards_count || 0,
|
||||
tags: attrs.season ? [attrs.season] : [],
|
||||
createdAt: item.created_at || "",
|
||||
updatedAt: item.updated_at || "",
|
||||
}
|
||||
}
|
||||
|
||||
// 获取服装列表
|
||||
export const getOutfits = async (params?: PaginationParams): Promise<PaginatedResponse<Outfit>> => {
|
||||
let filteredOutfits = [...mockOutfits]
|
||||
|
||||
// 搜索过滤
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase()
|
||||
filteredOutfits = filteredOutfits.filter(
|
||||
(outfit) =>
|
||||
outfit.name.toLowerCase().includes(search) ||
|
||||
outfit.id.toLowerCase().includes(search) ||
|
||||
outfit.designer.toLowerCase().includes(search) ||
|
||||
outfit.type.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (params?.sortBy) {
|
||||
filteredOutfits.sort((a: any, b: any) => {
|
||||
const aValue = a[params.sortBy!]
|
||||
const bValue = b[params.sortBy!]
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
|
||||
})
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = params?.page || 1
|
||||
const pageSize = params?.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const paginatedOutfits = filteredOutfits.slice(startIndex, startIndex + pageSize)
|
||||
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
|
||||
|
||||
return mockResponse({
|
||||
items: paginatedOutfits,
|
||||
total: filteredOutfits.length,
|
||||
const response = await apiClient.get(
|
||||
`/card/category/clothing/?page=${page}&page_size=${pageSize}${searchParam}`
|
||||
)
|
||||
|
||||
const data = response.data?.data || response.data
|
||||
const results: any[] = data.results || data
|
||||
const total = data.count || results.length
|
||||
|
||||
return {
|
||||
items: results.map(mapBackendOutfit),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(filteredOutfits.length / pageSize),
|
||||
})
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个服装
|
||||
export const getOutfit = async (id: string): Promise<Outfit> => {
|
||||
const outfit = mockOutfits.find((outfit) => outfit.id === id)
|
||||
|
||||
if (!outfit) {
|
||||
return mockResponse({} as Outfit, "服装不存在")
|
||||
}
|
||||
|
||||
return mockResponse(outfit)
|
||||
const response = await apiClient.get(`/card/templates/${id}/`)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendOutfit(data)
|
||||
}
|
||||
|
||||
// 创建服装
|
||||
export const createOutfit = async (outfitData: Partial<Outfit>): Promise<Outfit> => {
|
||||
// 生成新的服装ID
|
||||
const outfitId = "OUT" + String(mockOutfits.length + 1).padStart(3, "0")
|
||||
|
||||
const newOutfit: Outfit = {
|
||||
id: outfitId,
|
||||
name: outfitData.name || "",
|
||||
designer: outfitData.designer || "",
|
||||
export const createOutfit = async (outfitData: Partial<Outfit> & { rarityValue?: string }): Promise<Outfit> => {
|
||||
const payload: any = {
|
||||
name: outfitData.name,
|
||||
category: "clothing",
|
||||
description: outfitData.description || "",
|
||||
releaseDate: outfitData.releaseDate || "",
|
||||
status: outfitData.status || "未发布",
|
||||
type: outfitData.type || "",
|
||||
rarity: outfitData.rarity || "",
|
||||
image: outfitData.image || "/placeholder.svg?height=300&width=300",
|
||||
}
|
||||
if (outfitData.rarityValue) {
|
||||
payload.rarity = outfitData.rarityValue
|
||||
}
|
||||
if (outfitData.imageUrl) {
|
||||
payload.image_url = outfitData.imageUrl
|
||||
}
|
||||
if (outfitData.category) {
|
||||
payload.clothing_attributes = {
|
||||
style: outfitData.category,
|
||||
}
|
||||
}
|
||||
|
||||
mockOutfits.push(newOutfit)
|
||||
|
||||
return mockResponse(newOutfit)
|
||||
const response = await apiClient.post(`/card/templates/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendOutfit(data)
|
||||
}
|
||||
|
||||
// 更新服装
|
||||
export const updateOutfit = async (id: string, outfitData: Partial<Outfit>): Promise<Outfit> => {
|
||||
const outfitIndex = mockOutfits.findIndex((outfit) => outfit.id === id)
|
||||
export const updateOutfit = async (id: string, outfitData: Partial<Outfit> & { rarityValue?: string }): Promise<Outfit> => {
|
||||
const payload: any = {}
|
||||
if (outfitData.name !== undefined) payload.name = outfitData.name
|
||||
if (outfitData.description !== undefined) payload.description = outfitData.description
|
||||
if (outfitData.category !== undefined) payload.clothing_attributes = { style: outfitData.category }
|
||||
if (outfitData.rarityValue) payload.rarity = outfitData.rarityValue
|
||||
if (outfitData.imageUrl !== undefined) payload.image_url = outfitData.imageUrl
|
||||
|
||||
if (outfitIndex === -1) {
|
||||
return mockResponse({} as Outfit, "服装不存在")
|
||||
}
|
||||
|
||||
const updatedOutfit = {
|
||||
...mockOutfits[outfitIndex],
|
||||
...outfitData,
|
||||
}
|
||||
|
||||
mockOutfits[outfitIndex] = updatedOutfit
|
||||
|
||||
return mockResponse(updatedOutfit)
|
||||
const response = await apiClient.patch(`/card/templates/${id}/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendOutfit(data)
|
||||
}
|
||||
|
||||
// 删除服装
|
||||
export const deleteOutfit = async (id: string): Promise<boolean> => {
|
||||
const outfitIndex = mockOutfits.findIndex((outfit) => outfit.id === id)
|
||||
|
||||
if (outfitIndex === -1) {
|
||||
return mockResponse(false, "服装不存在")
|
||||
}
|
||||
|
||||
// 已发布的服装不能删除
|
||||
if (mockOutfits[outfitIndex].status === "已发布") {
|
||||
return mockResponse(false, "已发布的服装不能删除")
|
||||
}
|
||||
|
||||
mockOutfits.splice(outfitIndex, 1)
|
||||
|
||||
return mockResponse(true)
|
||||
await apiClient.delete(`/card/templates/${id}/`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 发布服装
|
||||
export const publishOutfit = async (id: string): Promise<Outfit> => {
|
||||
const outfitIndex = mockOutfits.findIndex((outfit) => outfit.id === id)
|
||||
|
||||
if (outfitIndex === -1) {
|
||||
return mockResponse({} as Outfit, "服装不存在")
|
||||
}
|
||||
|
||||
mockOutfits[outfitIndex].status = "已发布"
|
||||
mockOutfits[outfitIndex].releaseDate = new Date().toISOString().split("T")[0]
|
||||
|
||||
return mockResponse(mockOutfits[outfitIndex])
|
||||
const response = await apiClient.post(`/card/templates/${id}/publish/`)
|
||||
const data = response.data?.template || response.data
|
||||
return mapBackendOutfit(data)
|
||||
}
|
||||
|
||||
export const archiveOutfit = async (id: string): Promise<Outfit> => {
|
||||
const response = await apiClient.post(`/card/templates/${id}/archive/`)
|
||||
const data = response.data?.template || response.data
|
||||
return mapBackendOutfit(data)
|
||||
}
|
||||
|
||||
@ -1,188 +1,104 @@
|
||||
import type { Prop } from "./types"
|
||||
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
|
||||
import { apiClient } from "./client"
|
||||
import type { PaginatedResponse, PaginationParams } from "./client"
|
||||
|
||||
// 模拟道具数据
|
||||
const mockProps: Prop[] = [
|
||||
{
|
||||
id: "PRP001",
|
||||
name: "魔法麦克风",
|
||||
type: "演出道具",
|
||||
rarity: "稀有",
|
||||
description: "洛天依的经典原创道具,可以增强歌声的魔力,让听众更加沉浸在音乐中。",
|
||||
releaseDate: "2023-11-15",
|
||||
status: "已发布",
|
||||
activatedCount: 1245,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "PRP002",
|
||||
name: "星光魔杖",
|
||||
type: "互动道具",
|
||||
rarity: "史诗",
|
||||
description: "挥舞魔杖可以创造出美丽的星光效果,增加互动时的好感度。",
|
||||
releaseDate: "2023-12-01",
|
||||
status: "已发布",
|
||||
activatedCount: 876,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "PRP003",
|
||||
name: "音乐盒",
|
||||
type: "收藏品",
|
||||
rarity: "传说",
|
||||
description: "精美的音乐盒,打开后会播放洛天依的经典歌曲,是珍贵的收藏品。",
|
||||
releaseDate: "2024-01-10",
|
||||
status: "已发布",
|
||||
activatedCount: 532,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "PRP004",
|
||||
name: "虚拟相机",
|
||||
type: "互动道具",
|
||||
rarity: "稀有",
|
||||
description: "可以捕捉洛天依的精彩瞬间,保存为虚拟照片。",
|
||||
releaseDate: "2024-02-05",
|
||||
status: "已发布",
|
||||
activatedCount: 967,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
{
|
||||
id: "PRP005",
|
||||
name: "节日礼盒",
|
||||
type: "限定道具",
|
||||
rarity: "史诗",
|
||||
description: "节日限定礼盒,内含多种惊喜道具和装饰品。",
|
||||
releaseDate: "",
|
||||
status: "未发布",
|
||||
activatedCount: 0,
|
||||
image: "/placeholder.svg?height=300&width=300",
|
||||
},
|
||||
]
|
||||
function mapBackendProp(item: any): Prop {
|
||||
const attrs = item.attributes || {}
|
||||
return {
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
description: item.description || "",
|
||||
imageUrl: item.image_url || "/placeholder.svg?height=300&width=400",
|
||||
rarity: item.rarity_display || item.rarity || "",
|
||||
category: attrs.prop_type || "",
|
||||
status: item.status_display || item.status || "",
|
||||
publishedAt: item.published_at || "",
|
||||
batchesCount: item.batches_count || 0,
|
||||
activeCardsCount: item.active_cards_count || 0,
|
||||
tags: attrs.material ? [attrs.material] : [],
|
||||
createdAt: item.created_at || "",
|
||||
updatedAt: item.updated_at || "",
|
||||
}
|
||||
}
|
||||
|
||||
// 获取道具列表
|
||||
export const getProps = async (params?: PaginationParams): Promise<PaginatedResponse<Prop>> => {
|
||||
let filteredProps = [...mockProps]
|
||||
|
||||
// 搜索过滤
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase()
|
||||
filteredProps = filteredProps.filter(
|
||||
(prop) =>
|
||||
prop.name.toLowerCase().includes(search) ||
|
||||
prop.id.toLowerCase().includes(search) ||
|
||||
prop.type.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (params?.sortBy) {
|
||||
filteredProps.sort((a: any, b: any) => {
|
||||
const aValue = a[params.sortBy!]
|
||||
const bValue = b[params.sortBy!]
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
|
||||
})
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = params?.page || 1
|
||||
const pageSize = params?.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const paginatedProps = filteredProps.slice(startIndex, startIndex + pageSize)
|
||||
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
|
||||
|
||||
return mockResponse({
|
||||
items: paginatedProps,
|
||||
total: filteredProps.length,
|
||||
const response = await apiClient.get(
|
||||
`/card/category/prop/?page=${page}&page_size=${pageSize}${searchParam}`
|
||||
)
|
||||
|
||||
const data = response.data?.data || response.data
|
||||
const results: any[] = data.results || data
|
||||
const total = data.count || results.length
|
||||
|
||||
return {
|
||||
items: results.map(mapBackendProp),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(filteredProps.length / pageSize),
|
||||
})
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个道具
|
||||
export const getProp = async (id: string): Promise<Prop> => {
|
||||
const prop = mockProps.find((prop) => prop.id === id)
|
||||
|
||||
if (!prop) {
|
||||
return mockResponse({} as Prop, "道具不存在")
|
||||
}
|
||||
|
||||
return mockResponse(prop)
|
||||
const response = await apiClient.get(`/card/templates/${id}/`)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendProp(data)
|
||||
}
|
||||
|
||||
// 创建道具
|
||||
export const createProp = async (propData: Partial<Prop>): Promise<Prop> => {
|
||||
// 生成新的道具ID
|
||||
const propId = "PRP" + String(mockProps.length + 1).padStart(3, "0")
|
||||
|
||||
const newProp: Prop = {
|
||||
id: propId,
|
||||
name: propData.name || "",
|
||||
type: propData.type || "",
|
||||
rarity: propData.rarity || "",
|
||||
export const createProp = async (propData: Partial<Prop> & { rarityValue?: string }): Promise<Prop> => {
|
||||
const payload: any = {
|
||||
name: propData.name,
|
||||
category: "prop",
|
||||
description: propData.description || "",
|
||||
releaseDate: propData.releaseDate || "",
|
||||
status: propData.status || "未发布",
|
||||
activatedCount: 0,
|
||||
image: propData.image || "/placeholder.svg?height=300&width=300",
|
||||
}
|
||||
if (propData.rarityValue) {
|
||||
payload.rarity = propData.rarityValue
|
||||
}
|
||||
if (propData.imageUrl) {
|
||||
payload.image_url = propData.imageUrl
|
||||
}
|
||||
// 只在有实际属性值时才传 prop_attributes
|
||||
if (propData.category) {
|
||||
payload.prop_attributes = {
|
||||
prop_type: propData.category,
|
||||
}
|
||||
}
|
||||
|
||||
mockProps.push(newProp)
|
||||
|
||||
return mockResponse(newProp)
|
||||
const response = await apiClient.post(`/card/templates/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendProp(data)
|
||||
}
|
||||
|
||||
// 更新道具
|
||||
export const updateProp = async (id: string, propData: Partial<Prop>): Promise<Prop> => {
|
||||
const propIndex = mockProps.findIndex((prop) => prop.id === id)
|
||||
export const updateProp = async (id: string, propData: Partial<Prop> & { rarityValue?: string }): Promise<Prop> => {
|
||||
const payload: any = {}
|
||||
if (propData.name !== undefined) payload.name = propData.name
|
||||
if (propData.description !== undefined) payload.description = propData.description
|
||||
if (propData.category !== undefined) payload.prop_attributes = { prop_type: propData.category }
|
||||
if (propData.rarityValue) payload.rarity = propData.rarityValue
|
||||
if (propData.imageUrl !== undefined) payload.image_url = propData.imageUrl
|
||||
|
||||
if (propIndex === -1) {
|
||||
return mockResponse({} as Prop, "道具不存在")
|
||||
}
|
||||
|
||||
const updatedProp = {
|
||||
...mockProps[propIndex],
|
||||
...propData,
|
||||
}
|
||||
|
||||
mockProps[propIndex] = updatedProp
|
||||
|
||||
return mockResponse(updatedProp)
|
||||
const response = await apiClient.patch(`/card/templates/${id}/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendProp(data)
|
||||
}
|
||||
|
||||
// 删除道具
|
||||
export const deleteProp = async (id: string): Promise<boolean> => {
|
||||
const propIndex = mockProps.findIndex((prop) => prop.id === id)
|
||||
|
||||
if (propIndex === -1) {
|
||||
return mockResponse(false, "道具不存在")
|
||||
}
|
||||
|
||||
// 已发布的道具不能删除
|
||||
if (mockProps[propIndex].status === "已发布") {
|
||||
return mockResponse(false, "已发布的道具不能删除")
|
||||
}
|
||||
|
||||
mockProps.splice(propIndex, 1)
|
||||
|
||||
return mockResponse(true)
|
||||
await apiClient.delete(`/card/templates/${id}/`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 发布道具
|
||||
export const publishProp = async (id: string): Promise<Prop> => {
|
||||
const propIndex = mockProps.findIndex((prop) => prop.id === id)
|
||||
|
||||
if (propIndex === -1) {
|
||||
return mockResponse({} as Prop, "道具不存在")
|
||||
}
|
||||
|
||||
mockProps[propIndex].status = "已发布"
|
||||
mockProps[propIndex].releaseDate = new Date().toISOString().split("T")[0]
|
||||
|
||||
return mockResponse(mockProps[propIndex])
|
||||
const response = await apiClient.post(`/card/templates/${id}/publish/`)
|
||||
const data = response.data?.template || response.data
|
||||
return mapBackendProp(data)
|
||||
}
|
||||
|
||||
export const archiveProp = async (id: string): Promise<Prop> => {
|
||||
const response = await apiClient.post(`/card/templates/${id}/archive/`)
|
||||
const data = response.data?.template || response.data
|
||||
return mapBackendProp(data)
|
||||
}
|
||||
|
||||
@ -1,177 +1,70 @@
|
||||
import type { Role } from "./types"
|
||||
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
|
||||
import { apiClient } from "./client"
|
||||
import type { PaginatedResponse, PaginationParams } from "./client"
|
||||
|
||||
// 模拟角色数据
|
||||
const mockRoles: Role[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "超级管理员",
|
||||
description: "拥有系统所有权限",
|
||||
permissions: ["all"],
|
||||
userCount: 1,
|
||||
createdAt: "2023-01-01",
|
||||
updatedAt: "2023-01-01",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "内容管理员",
|
||||
description: "管理系统内容,包括服装、道具、歌曲等",
|
||||
permissions: ["content_view", "content_edit", "content_delete"],
|
||||
userCount: 3,
|
||||
createdAt: "2023-01-15",
|
||||
updatedAt: "2023-03-20",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "AI模型管理员",
|
||||
description: "管理AI模型的训练、部署和监控",
|
||||
permissions: ["ai_model_view", "ai_model_edit", "ai_model_deploy"],
|
||||
userCount: 2,
|
||||
createdAt: "2023-02-10",
|
||||
updatedAt: "2023-04-15",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "卡牌管理员",
|
||||
description: "管理卡牌系统,包括服装卡、道具卡等",
|
||||
permissions: ["card_view", "card_edit", "card_delete", "card_print"],
|
||||
userCount: 4,
|
||||
createdAt: "2023-03-05",
|
||||
updatedAt: "2023-05-10",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "查看者",
|
||||
description: "只有查看权限,无法编辑或删除内容",
|
||||
permissions: ["dashboard_view", "content_view"],
|
||||
userCount: 8,
|
||||
createdAt: "2023-01-20",
|
||||
updatedAt: "2023-01-20",
|
||||
},
|
||||
]
|
||||
// 将后端 Django Group 数据映射到前端 Role 类型
|
||||
function mapBackendGroup(g: any): Role {
|
||||
return {
|
||||
id: String(g.id),
|
||||
name: g.name,
|
||||
description: undefined,
|
||||
permissions: g.permissions || [],
|
||||
userCount: g.user_count || 0,
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 获取角色列表
|
||||
// 获取角色(Group)列表
|
||||
export const getRoles = async (params?: PaginationParams): Promise<PaginatedResponse<Role>> => {
|
||||
let filteredRoles = [...mockRoles]
|
||||
|
||||
// 搜索过滤
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase()
|
||||
filteredRoles = filteredRoles.filter(
|
||||
(role) =>
|
||||
role.name.toLowerCase().includes(search) ||
|
||||
(role.description && role.description.toLowerCase().includes(search)),
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (params?.sortBy) {
|
||||
filteredRoles.sort((a: any, b: any) => {
|
||||
const aValue = a[params.sortBy!]
|
||||
const bValue = b[params.sortBy!]
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
|
||||
})
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = params?.page || 1
|
||||
const pageSize = params?.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const paginatedRoles = filteredRoles.slice(startIndex, startIndex + pageSize)
|
||||
|
||||
return mockResponse({
|
||||
items: paginatedRoles,
|
||||
total: filteredRoles.length,
|
||||
const response = await apiClient.get(`/user/groups/?page=${page}&page_size=${pageSize}`)
|
||||
|
||||
const data = response.data?.data || response.data
|
||||
const results: any[] = data.results || (Array.isArray(data) ? data : [])
|
||||
const total = data.count || results.length
|
||||
|
||||
return {
|
||||
items: results.map(mapBackendGroup),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(filteredRoles.length / pageSize),
|
||||
})
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个角色
|
||||
export const getRole = async (id: string): Promise<Role> => {
|
||||
const role = mockRoles.find((role) => role.id === id)
|
||||
|
||||
if (!role) {
|
||||
return mockResponse({} as Role, "角色不存在")
|
||||
}
|
||||
|
||||
return mockResponse(role)
|
||||
const response = await apiClient.get(`/user/groups/${id}/`)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendGroup(data)
|
||||
}
|
||||
|
||||
// 创建角色
|
||||
// 创建角色(需要管理员权限)
|
||||
export const createRole = async (roleData: Partial<Role>): Promise<Role> => {
|
||||
// 检查角色名是否已存在
|
||||
if (mockRoles.some((role) => role.name === roleData.name)) {
|
||||
return mockResponse({} as Role, "该角色名已存在")
|
||||
const payload = {
|
||||
name: roleData.name,
|
||||
}
|
||||
|
||||
const newRole: Role = {
|
||||
id: String(mockRoles.length + 1),
|
||||
name: roleData.name || "",
|
||||
description: roleData.description,
|
||||
permissions: roleData.permissions || [],
|
||||
userCount: 0,
|
||||
createdAt: new Date().toISOString().split("T")[0],
|
||||
updatedAt: new Date().toISOString().split("T")[0],
|
||||
}
|
||||
|
||||
mockRoles.push(newRole)
|
||||
|
||||
return mockResponse(newRole)
|
||||
const response = await apiClient.post(`/user/groups/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendGroup(data)
|
||||
}
|
||||
|
||||
// 更新角色
|
||||
// 更新角色(需要管理员权限)
|
||||
export const updateRole = async (id: string, roleData: Partial<Role>): Promise<Role> => {
|
||||
const roleIndex = mockRoles.findIndex((role) => role.id === id)
|
||||
const payload: any = {}
|
||||
if (roleData.name !== undefined) payload.name = roleData.name
|
||||
|
||||
if (roleIndex === -1) {
|
||||
return mockResponse({} as Role, "角色不存在")
|
||||
}
|
||||
|
||||
// 检查角色名是否已被其他角色使用
|
||||
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)
|
||||
const response = await apiClient.patch(`/user/groups/${id}/`, payload)
|
||||
const data = response.data?.data || response.data
|
||||
return mapBackendGroup(data)
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
// 删除角色(需要管理员权限)
|
||||
export const deleteRole = async (id: string): Promise<boolean> => {
|
||||
const roleIndex = mockRoles.findIndex((role) => role.id === id)
|
||||
|
||||
if (roleIndex === -1) {
|
||||
return mockResponse(false, "角色不存在")
|
||||
}
|
||||
|
||||
// 超级管理员不能删除
|
||||
if (mockRoles[roleIndex].name === "超级管理员") {
|
||||
return mockResponse(false, "不能删除超级管理员角色")
|
||||
}
|
||||
|
||||
// 有用户使用的角色不能删除
|
||||
if (mockRoles[roleIndex].userCount && mockRoles[roleIndex].userCount > 0) {
|
||||
return mockResponse(false, "该角色下有用户,不能删除")
|
||||
}
|
||||
|
||||
mockRoles.splice(roleIndex, 1)
|
||||
|
||||
return mockResponse(true)
|
||||
await apiClient.delete(`/user/groups/${id}/`)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -64,6 +64,10 @@ export interface Outfit {
|
||||
imageUrl?: string
|
||||
rarity?: string
|
||||
category?: string
|
||||
status?: string
|
||||
publishedAt?: string
|
||||
batchesCount?: number
|
||||
activeCardsCount?: number
|
||||
tags?: string[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
@ -77,6 +81,10 @@ export interface Prop {
|
||||
imageUrl?: string
|
||||
rarity?: string
|
||||
category?: string
|
||||
status?: string
|
||||
publishedAt?: string
|
||||
batchesCount?: number
|
||||
activeCardsCount?: number
|
||||
tags?: string[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
@ -244,7 +252,12 @@ export interface HomeDecor {
|
||||
name: string
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
rarity?: string
|
||||
category?: string
|
||||
status?: string
|
||||
publishedAt?: string
|
||||
batchesCount?: number
|
||||
activeCardsCount?: number
|
||||
tags?: string[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
|
||||
@ -34,18 +34,18 @@ export const uploadFile = async (
|
||||
|
||||
const response = await apiClient.post('/common/upload/', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Content-Type': undefined as any, // Let axios/browser set multipart boundary automatically
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const { loaded, total } = progressEvent;
|
||||
const percentage = total ? Math.round((loaded * 100) / total) : 0;
|
||||
|
||||
console.log(`📊 上传进度: ${percentage}% (${(loaded / 1024 / 1024).toFixed(2)}MB / ${(total / 1024 / 1024).toFixed(2)}MB)`);
|
||||
|
||||
|
||||
console.log(`📊 上传进度: ${percentage}% (${(loaded / 1024 / 1024).toFixed(2)}MB / ${((total || 0) / 1024 / 1024).toFixed(2)}MB)`);
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
loaded,
|
||||
total,
|
||||
total: total || 0,
|
||||
percentage,
|
||||
});
|
||||
}
|
||||
@ -84,18 +84,18 @@ export const uploadFiles = async (
|
||||
|
||||
const response = await apiClient.post('/common/upload/', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Content-Type': undefined as any, // Let axios/browser set multipart boundary automatically
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const { loaded, total } = progressEvent;
|
||||
const percentage = total ? Math.round((loaded * 100) / total) : 0;
|
||||
|
||||
|
||||
console.log(`📊 批量上传进度: ${percentage}%`);
|
||||
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
loaded,
|
||||
total,
|
||||
total: total || 0,
|
||||
percentage,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,225 +1,100 @@
|
||||
import type { User, LoginHistory } from "./types"
|
||||
import { mockResponse, type PaginatedResponse, type PaginationParams } from "./client"
|
||||
import { apiClient } from "./client"
|
||||
import type { PaginatedResponse, PaginationParams } from "./client"
|
||||
|
||||
// 模拟用户数据
|
||||
const mockUsers: User[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "管理员",
|
||||
email: "admin@example.com",
|
||||
role: "超级管理员",
|
||||
status: "活跃",
|
||||
registeredAt: "2023-01-01",
|
||||
lastLogin: "今天 08:45",
|
||||
phone: "13800138000",
|
||||
address: "北京市海淀区中关村",
|
||||
permissions: [
|
||||
"仪表盘查看",
|
||||
"用户管理",
|
||||
"角色权限管理",
|
||||
"AI模型管理",
|
||||
"服装管理",
|
||||
"道具管理",
|
||||
"歌曲管理",
|
||||
"系统设置",
|
||||
],
|
||||
loginHistory: [
|
||||
{ date: "2023-05-15 08:45", ip: "192.168.1.1", device: "Chrome / Windows" },
|
||||
{ date: "2023-05-14 15:30", ip: "192.168.1.1", device: "Chrome / Windows" },
|
||||
{ date: "2023-05-13 09:20", ip: "192.168.1.1", device: "Chrome / Windows" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "张三",
|
||||
email: "zhangsan@example.com",
|
||||
role: "内容管理员",
|
||||
status: "活跃",
|
||||
registeredAt: "2023-03-15",
|
||||
lastLogin: "昨天 16:30",
|
||||
phone: "13900139000",
|
||||
permissions: ["仪表盘查看", "服装管理", "道具管理", "歌曲管理"],
|
||||
loginHistory: [
|
||||
{ date: "2023-05-14 16:30", ip: "192.168.1.2", device: "Safari / macOS" },
|
||||
{ date: "2023-05-13 14:20", ip: "192.168.1.2", device: "Safari / macOS" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "李四",
|
||||
email: "lisi@example.com",
|
||||
role: "AI模型管理员",
|
||||
status: "活跃",
|
||||
registeredAt: "2023-05-20",
|
||||
lastLogin: "3天前",
|
||||
permissions: ["仪表盘查看", "AI模型管理"],
|
||||
loginHistory: [{ date: "2023-05-12 10:15", ip: "192.168.1.3", device: "Firefox / Ubuntu" }],
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "王五",
|
||||
email: "wangwu@example.com",
|
||||
role: "卡牌管理员",
|
||||
status: "活跃",
|
||||
registeredAt: "2023-07-10",
|
||||
lastLogin: "1周前",
|
||||
permissions: ["仪表盘查看", "服装管理", "道具管理", "家居装饰管理"],
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "赵六",
|
||||
email: "zhaoliu@example.com",
|
||||
role: "查看者",
|
||||
status: "未激活",
|
||||
registeredAt: "2023-09-05",
|
||||
permissions: ["仪表盘查看"],
|
||||
},
|
||||
]
|
||||
// 将后端 ParadiseUser 数据映射到前端 User 类型
|
||||
function mapBackendUser(u: any): User {
|
||||
return {
|
||||
id: String(u.id),
|
||||
name: u.username || u.email || u.phone_number || "",
|
||||
email: u.email || "",
|
||||
role: u.is_superuser ? "超级管理员" : u.is_staff ? "管理员" : "普通用户",
|
||||
status: u.is_active ? "活跃" : "未激活",
|
||||
registeredAt: u.date_joined ? u.date_joined.split("T")[0] : "",
|
||||
lastLogin: u.last_login ? u.last_login.split("T")[0] : undefined,
|
||||
phone: u.phone_number || undefined,
|
||||
address: u.resident_city || undefined,
|
||||
avatar: undefined,
|
||||
permissions: [],
|
||||
loginHistory: [],
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
export const getUsers = async (params?: PaginationParams): Promise<PaginatedResponse<User>> => {
|
||||
let filteredUsers = [...mockUsers]
|
||||
|
||||
// 搜索过滤
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase()
|
||||
filteredUsers = filteredUsers.filter(
|
||||
(user) =>
|
||||
user.name.toLowerCase().includes(search) ||
|
||||
user.email.toLowerCase().includes(search) ||
|
||||
user.role.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (params?.sortBy) {
|
||||
filteredUsers.sort((a: any, b: any) => {
|
||||
const aValue = a[params.sortBy!]
|
||||
const bValue = b[params.sortBy!]
|
||||
|
||||
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||
return params.sortOrder === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
return params.sortOrder === "desc" ? bValue - aValue : aValue - bValue
|
||||
})
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = params?.page || 1
|
||||
const pageSize = params?.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const paginatedUsers = filteredUsers.slice(startIndex, startIndex + pageSize)
|
||||
const searchParam = params?.search ? `&search=${encodeURIComponent(params.search)}` : ""
|
||||
|
||||
return mockResponse({
|
||||
items: paginatedUsers,
|
||||
total: filteredUsers.length,
|
||||
const response = await apiClient.get(`/user/?page=${page}&page_size=${pageSize}${searchParam}`)
|
||||
const data = response.data
|
||||
|
||||
// 后端直接返回分页数据(DRF 默认格式)
|
||||
const results: any[] = data.results || data
|
||||
const total = data.count || results.length
|
||||
|
||||
return {
|
||||
items: results.map(mapBackendUser),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(filteredUsers.length / pageSize),
|
||||
})
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个用户
|
||||
export const getUser = async (id: string): Promise<User> => {
|
||||
const user = mockUsers.find((user) => user.id === id)
|
||||
|
||||
if (!user) {
|
||||
return mockResponse({} as User, "用户不存在")
|
||||
}
|
||||
|
||||
return mockResponse(user)
|
||||
const response = await apiClient.get(`/user/${id}/`)
|
||||
return mapBackendUser(response.data)
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
export const createUser = async (userData: Partial<User>): Promise<User> => {
|
||||
// 检查邮箱是否已存在
|
||||
if (mockUsers.some((user) => user.email === userData.email)) {
|
||||
return mockResponse({} as User, "该邮箱已被注册")
|
||||
export const createUser = async (userData: Partial<User> & { password?: string }): Promise<User> => {
|
||||
const payload: any = {
|
||||
username: userData.name,
|
||||
email: userData.email,
|
||||
phone_number: userData.phone,
|
||||
password1: userData.password || "changeme123",
|
||||
password2: userData.password || "changeme123",
|
||||
is_active: userData.status === "活跃",
|
||||
is_staff: userData.role === "管理员" || userData.role === "超级管理员",
|
||||
}
|
||||
|
||||
const newUser: User = {
|
||||
id: String(mockUsers.length + 1),
|
||||
name: userData.name || "",
|
||||
email: userData.email || "",
|
||||
role: userData.role || "查看者",
|
||||
status: userData.status || "未激活",
|
||||
registeredAt: new Date().toISOString().split("T")[0],
|
||||
phone: userData.phone,
|
||||
address: userData.address,
|
||||
permissions: [],
|
||||
loginHistory: [],
|
||||
}
|
||||
|
||||
mockUsers.push(newUser)
|
||||
|
||||
return mockResponse(newUser)
|
||||
const response = await apiClient.post(`/user/`, payload)
|
||||
return mapBackendUser(response.data)
|
||||
}
|
||||
|
||||
// 更新用户
|
||||
export const updateUser = async (id: string, userData: Partial<User>): Promise<User> => {
|
||||
const userIndex = mockUsers.findIndex((user) => user.id === id)
|
||||
|
||||
if (userIndex === -1) {
|
||||
return mockResponse({} as User, "用户不存在")
|
||||
const payload: any = {}
|
||||
if (userData.name !== undefined) payload.username = userData.name
|
||||
if (userData.email !== undefined) payload.email = userData.email
|
||||
if (userData.phone !== undefined) payload.phone_number = userData.phone
|
||||
if (userData.address !== undefined) payload.resident_city = userData.address
|
||||
if (userData.status !== undefined) payload.is_active = userData.status === "活跃"
|
||||
if (userData.role !== undefined) {
|
||||
payload.is_staff = userData.role === "管理员" || userData.role === "超级管理员"
|
||||
payload.is_superuser = userData.role === "超级管理员"
|
||||
}
|
||||
|
||||
// 检查邮箱是否已被其他用户使用
|
||||
if (userData.email && userData.email !== mockUsers[userIndex].email) {
|
||||
const emailExists = mockUsers.some((user) => user.email === userData.email && user.id !== id)
|
||||
if (emailExists) {
|
||||
return mockResponse({} as User, "该邮箱已被其他用户使用")
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = {
|
||||
...mockUsers[userIndex],
|
||||
...userData,
|
||||
}
|
||||
|
||||
mockUsers[userIndex] = updatedUser
|
||||
|
||||
return mockResponse(updatedUser)
|
||||
const response = await apiClient.patch(`/user/${id}/`, payload)
|
||||
return mapBackendUser(response.data)
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
export const deleteUser = async (id: string): Promise<boolean> => {
|
||||
const userIndex = mockUsers.findIndex((user) => user.id === id)
|
||||
|
||||
if (userIndex === -1) {
|
||||
return mockResponse(false, "用户不存在")
|
||||
}
|
||||
|
||||
// 超级管理员不能删除
|
||||
if (mockUsers[userIndex].role === "超级管理员") {
|
||||
return mockResponse(false, "不能删除超级管理员")
|
||||
}
|
||||
|
||||
mockUsers.splice(userIndex, 1)
|
||||
|
||||
return mockResponse(true)
|
||||
}
|
||||
|
||||
// 获取用户登录历史
|
||||
export const getUserLoginHistory = async (id: string): Promise<LoginHistory[]> => {
|
||||
const user = mockUsers.find((user) => user.id === id)
|
||||
|
||||
if (!user) {
|
||||
return mockResponse([], "用户不存在")
|
||||
}
|
||||
|
||||
return mockResponse(user.loginHistory || [])
|
||||
await apiClient.delete(`/user/${id}/`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 更改用户状态
|
||||
export const changeUserStatus = async (id: string, status: User["status"]): Promise<User> => {
|
||||
const userIndex = mockUsers.findIndex((user) => user.id === id)
|
||||
|
||||
if (userIndex === -1) {
|
||||
return mockResponse({} as User, "用户不存在")
|
||||
}
|
||||
|
||||
mockUsers[userIndex].status = status
|
||||
|
||||
return mockResponse(mockUsers[userIndex])
|
||||
const response = await apiClient.patch(`/user/${id}/`, {
|
||||
is_active: status === "活跃",
|
||||
})
|
||||
return mapBackendUser(response.data)
|
||||
}
|
||||
|
||||
// 获取用户登录历史(后端暂无此接口,返回空数组)
|
||||
export const getUserLoginHistory = async (_id: string): Promise<LoginHistory[]> => {
|
||||
return []
|
||||
}
|
||||
|
||||
122
qy-lty-admin/lib/permissions.ts
Normal file
122
qy-lty-admin/lib/permissions.ts
Normal 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);
|
||||
}
|
||||
36
qy-lty-admin/package-lock.json
generated
36
qy-lty-admin/package-lock.json
generated
@ -238,9 +238,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -257,9 +254,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -276,9 +270,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -295,9 +286,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -314,9 +302,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -333,9 +318,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -352,9 +334,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -377,9 +356,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -402,9 +378,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -427,9 +400,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -452,9 +422,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -477,9 +444,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@ -48,17 +48,10 @@
|
||||
dependencies:
|
||||
"@standard-schema/utils" "^0.3.0"
|
||||
|
||||
"@img/sharp-darwin-arm64@0.33.5":
|
||||
"@img/sharp-win32-x64@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz"
|
||||
integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-darwin-arm64" "1.0.4"
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz"
|
||||
integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==
|
||||
resolved "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz"
|
||||
integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==
|
||||
|
||||
"@isaacs/cliui@^8.0.2":
|
||||
version "8.0.2"
|
||||
@ -109,10 +102,10 @@
|
||||
resolved "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz"
|
||||
integrity sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==
|
||||
|
||||
"@next/swc-darwin-arm64@15.2.4":
|
||||
"@next/swc-win32-x64-msvc@15.2.4":
|
||||
version "15.2.4"
|
||||
resolved "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz"
|
||||
integrity sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==
|
||||
resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz"
|
||||
integrity sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==
|
||||
|
||||
"@nodelib/fs.scandir@2.1.5":
|
||||
version "2.1.5"
|
||||
|
||||
@ -15,13 +15,21 @@ from .serializers import (
|
||||
UserAchievementListSerializer
|
||||
)
|
||||
|
||||
class AchievementViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class IsAdminOrReadOnly(permissions.BasePermission):
|
||||
"""管理员可写,其他人只读"""
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return request.user and request.user.is_authenticated
|
||||
return request.user and request.user.is_staff
|
||||
|
||||
|
||||
class AchievementViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
成就相关视图集
|
||||
提供成就的只读操作
|
||||
管理员可增删改查,普通用户只读
|
||||
"""
|
||||
serializer_class = AchievementSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAdminOrReadOnly]
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
from django.urls import path
|
||||
from .views import ChatBotAPIView, MultiChatAPIView
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ChatBotAPIView, MultiChatAPIView, BotViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'bots', BotViewSet, basename='bot')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('chat/<int:bot_id>/', ChatBotAPIView.as_view(), name='chat-bot'),
|
||||
path('multichat/', MultiChatAPIView.as_view(), name='multi-chat'),
|
||||
]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework import status, viewsets, permissions
|
||||
from django.contrib.auth.models import User
|
||||
from .models import ChatMessage, Bot
|
||||
from userapp.models import ParadiseUser
|
||||
@ -16,6 +16,30 @@ import requests
|
||||
import re
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Bot
|
||||
fields = ['id', 'name', 'description']
|
||||
|
||||
|
||||
class IsAdminOrReadOnly(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return request.user and request.user.is_authenticated
|
||||
return request.user and request.user.is_staff
|
||||
|
||||
|
||||
class BotViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
机器人(AI模型)管理接口
|
||||
管理员可增删改查,普通用户只读
|
||||
"""
|
||||
queryset = Bot.objects.all()
|
||||
serializer_class = BotSerializer
|
||||
permission_classes = [IsAdminOrReadOnly]
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
|
||||
from .kimi import KIMI
|
||||
from .audio.AudioService import get_audio_service
|
||||
|
||||
|
||||
@ -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='尺寸'),
|
||||
),
|
||||
]
|
||||
@ -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='风格'),
|
||||
),
|
||||
]
|
||||
@ -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='风格'),
|
||||
),
|
||||
]
|
||||
@ -53,13 +53,7 @@ class CardTemplate(models.Model):
|
||||
rarity = models.CharField('稀有度', max_length=20, choices=RARITY_CHOICES, default='common')
|
||||
|
||||
# Display image
|
||||
image = models.ImageField(
|
||||
'展示图片',
|
||||
upload_to='templates/images/',
|
||||
storage=oss_storage,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
image = models.URLField('展示图片', max_length=500, blank=True, default='')
|
||||
|
||||
# 3D model information
|
||||
model_url = models.URLField('模型包链接', max_length=255, blank=True, null=True)
|
||||
@ -267,13 +261,13 @@ class CardUsageLog(models.Model):
|
||||
class ClothingAttributes(models.Model):
|
||||
"""服装卡牌的特殊属性"""
|
||||
template = models.OneToOneField(CardTemplate, verbose_name='卡片模板', on_delete=models.CASCADE, related_name='clothing_attrs')
|
||||
style = models.CharField('风格', max_length=50)
|
||||
size = models.CharField('尺码', max_length=20)
|
||||
color = models.CharField('颜色', max_length=50)
|
||||
season = models.CharField('季节', max_length=20)
|
||||
material = models.CharField('材质', max_length=100)
|
||||
fit_type = models.CharField('版型', max_length=50)
|
||||
care_instructions = models.TextField('保养说明', blank=True)
|
||||
style = models.CharField('风格', max_length=50, blank=True, default='')
|
||||
size = models.CharField('尺码', max_length=20, blank=True, default='')
|
||||
color = models.CharField('颜色', max_length=50, blank=True, default='')
|
||||
season = models.CharField('季节', max_length=20, blank=True, default='')
|
||||
material = models.CharField('材质', max_length=100, blank=True, default='')
|
||||
fit_type = models.CharField('版型', max_length=50, blank=True, default='')
|
||||
care_instructions = models.TextField('保养说明', blank=True, default='')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '服装属性'
|
||||
@ -282,11 +276,11 @@ class ClothingAttributes(models.Model):
|
||||
class PropAttributes(models.Model):
|
||||
"""道具卡牌的特殊属性"""
|
||||
template = models.OneToOneField(CardTemplate, verbose_name='卡片模板', on_delete=models.CASCADE, related_name='prop_attrs')
|
||||
prop_type = models.CharField('道具类型', max_length=50)
|
||||
material = models.CharField('材质', max_length=100)
|
||||
size = models.CharField('尺寸', max_length=50)
|
||||
prop_type = models.CharField('道具类型', max_length=50, blank=True, default='')
|
||||
material = models.CharField('材质', max_length=100, blank=True, default='')
|
||||
size = models.CharField('尺寸', max_length=50, blank=True, default='')
|
||||
weight = models.FloatField('重量(g)', null=True, blank=True)
|
||||
durability = models.CharField('耐久度', max_length=50)
|
||||
durability = models.CharField('耐久度', max_length=50, blank=True, default='')
|
||||
usage_instructions = models.TextField('使用说明', blank=True)
|
||||
|
||||
class Meta:
|
||||
@ -327,14 +321,14 @@ class DanceAttributes(models.Model):
|
||||
class FurnitureAttributes(models.Model):
|
||||
"""家具卡牌的特殊属性"""
|
||||
template = models.OneToOneField(CardTemplate, verbose_name='卡片模板', on_delete=models.CASCADE, related_name='furniture_attrs')
|
||||
furniture_type = models.CharField('家具类型', max_length=50)
|
||||
style = models.CharField('风格', max_length=50)
|
||||
material = models.CharField('材质', max_length=100)
|
||||
dimensions = models.CharField('尺寸', max_length=100)
|
||||
furniture_type = models.CharField('家具类型', max_length=50, blank=True, default='')
|
||||
style = models.CharField('风格', max_length=50, blank=True, default='')
|
||||
material = models.CharField('材质', max_length=100, blank=True, default='')
|
||||
dimensions = models.CharField('尺寸', max_length=100, blank=True, default='')
|
||||
weight = models.FloatField('重量(kg)', null=True, blank=True)
|
||||
assembly_required = models.BooleanField('需要组装', default=False)
|
||||
max_weight_capacity = models.FloatField('最大承重(kg)', null=True, blank=True)
|
||||
care_instructions = models.TextField('保养说明', blank=True)
|
||||
care_instructions = models.TextField('保养说明', blank=True, default='')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '家具属性'
|
||||
@ -343,14 +337,14 @@ class FurnitureAttributes(models.Model):
|
||||
class DecorationAttributes(models.Model):
|
||||
"""装饰卡牌的特殊属性"""
|
||||
template = models.OneToOneField(CardTemplate, verbose_name='卡片模板', on_delete=models.CASCADE, related_name='decoration_attrs')
|
||||
decoration_type = models.CharField('装饰类型', max_length=50)
|
||||
style = models.CharField('风格', max_length=50)
|
||||
material = models.CharField('材质', max_length=100)
|
||||
size = models.CharField('尺寸', max_length=50)
|
||||
placement = models.CharField('摆放位置', max_length=50)
|
||||
indoor_outdoor = models.CharField('室内/室外', max_length=20)
|
||||
decoration_type = models.CharField('装饰类型', max_length=50, blank=True, default='')
|
||||
style = models.CharField('风格', max_length=50, blank=True, default='')
|
||||
material = models.CharField('材质', max_length=100, blank=True, default='')
|
||||
size = models.CharField('尺寸', max_length=50, blank=True, default='')
|
||||
placement = models.CharField('摆放位置', max_length=50, blank=True, default='')
|
||||
indoor_outdoor = models.CharField('室内/室外', max_length=20, blank=True, default='')
|
||||
installation_required = models.BooleanField('需要安装', default=False)
|
||||
care_instructions = models.TextField('保养说明', blank=True)
|
||||
care_instructions = models.TextField('保养说明', blank=True, default='')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '装饰属性'
|
||||
|
||||
@ -65,8 +65,8 @@ class CardTemplateSerializer(serializers.ModelSerializer):
|
||||
rarity_display = serializers.SerializerMethodField()
|
||||
card_type_display = serializers.SerializerMethodField()
|
||||
status_display = serializers.SerializerMethodField()
|
||||
image_url = serializers.SerializerMethodField()
|
||||
|
||||
image_url = serializers.URLField(source='image', required=False, allow_blank=True)
|
||||
|
||||
# 新增:所有类别专有属性的write_only字段
|
||||
clothing_attributes = ClothingAttributesSerializer(write_only=True, required=False)
|
||||
prop_attributes = PropAttributesSerializer(write_only=True, required=False)
|
||||
@ -86,10 +86,11 @@ class CardTemplateSerializer(serializers.ModelSerializer):
|
||||
# 新增
|
||||
'clothing_attributes', 'prop_attributes', 'song_attributes', 'dance_attributes', 'furniture_attributes', 'decoration_attributes',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'published_at', 'image_url']
|
||||
read_only_fields = ['created_at', 'updated_at', 'published_at']
|
||||
|
||||
def create(self, validated_data):
|
||||
# 把所有 *_attributes 字段pop掉,只留主表字段
|
||||
# 属性的创建由 View 层的 create 方法负责
|
||||
validated_data.pop('clothing_attributes', None)
|
||||
validated_data.pop('prop_attributes', None)
|
||||
validated_data.pop('song_attributes', None)
|
||||
@ -110,11 +111,6 @@ class CardTemplateSerializer(serializers.ModelSerializer):
|
||||
def get_status_display(self, obj):
|
||||
return obj.get_status_display()
|
||||
|
||||
def get_image_url(self, obj):
|
||||
if obj.image:
|
||||
return obj.image.url
|
||||
return None
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
# 添加类别专有属性
|
||||
@ -247,9 +243,9 @@ class CardSerializer(serializers.ModelSerializer):
|
||||
def get_image_url(self, obj):
|
||||
if obj.image:
|
||||
return obj.image.url
|
||||
# 如果卡片没有图片,使用模板的图片
|
||||
# 如果卡片没有图片,使用模板的图片(模板image现在是URLField字符串)
|
||||
elif obj.template and obj.template.image:
|
||||
return obj.template.image.url
|
||||
return obj.template.image
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@ from .oss import OSSUploader
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
import logging
|
||||
import traceback
|
||||
import os
|
||||
from userapp.authentication import RedisTokenAuthentication
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -70,23 +72,43 @@ def upload_file(request):
|
||||
try:
|
||||
# 检查是否有文件
|
||||
if 'file' not in request.FILES:
|
||||
print("[Upload] No file in request.FILES, keys:", list(request.FILES.keys()))
|
||||
return Response({"error": "No file provided"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
file_obj = request.FILES['file']
|
||||
|
||||
original_name = file_obj.name
|
||||
file_size = file_obj.size
|
||||
content_type = getattr(file_obj, 'content_type', 'unknown')
|
||||
print(f"[Upload] File received: {original_name}, size={file_size}, type={content_type}")
|
||||
|
||||
# 获取参数
|
||||
is_permanent = request.data.get('is_permanent', 'false').lower() == 'true'
|
||||
is_permanent_raw = request.data.get('is_permanent', 'false')
|
||||
is_permanent = str(is_permanent_raw).lower() == 'true'
|
||||
filename = request.data.get('filename', None)
|
||||
|
||||
|
||||
# 上传文件
|
||||
uploader = OSSUploader()
|
||||
result = uploader.upload_file(file_obj, is_permanent, filename)
|
||||
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
# 返回包含前端需要的字段
|
||||
_, ext = os.path.splitext(original_name)
|
||||
response_data = {
|
||||
'url': result['url'],
|
||||
'key': result['key'],
|
||||
'is_permanent': result['is_permanent'],
|
||||
'filename': original_name,
|
||||
'size': file_size,
|
||||
'mimeType': content_type,
|
||||
}
|
||||
|
||||
print(f"[Upload] Success: {result['url']}")
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"File upload failed: {str(e)}")
|
||||
error_detail = traceback.format_exc()
|
||||
print(f"[Upload] ERROR: {error_detail}")
|
||||
logger.error(f"File upload failed: {str(e)}\n{error_detail}")
|
||||
return Response(
|
||||
{"error": "File upload failed", "detail": str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
)
|
||||
683
qy_lty/docs/修改指南_服务器端.md
Normal file
683
qy_lty/docs/修改指南_服务器端.md
Normal 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)
|
||||
32
qy_lty/docs/修改记录.md
Normal file
32
qy_lty/docs/修改记录.md
Normal 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,保证唯一性。
|
||||
|
||||
1033
qy_lty/docs/设备动态绑定方案.md
Normal file
1033
qy_lty/docs/设备动态绑定方案.md
Normal file
File diff suppressed because it is too large
Load Diff
238
qy_lty/docs/项目功能介绍.md
Normal file
238
qy_lty/docs/项目功能介绍.md
Normal 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 API(add_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 模型 | Kimi(OpenAI 兼容接口) |
|
||||
| 语音服务 | 阿里云 / 腾讯 / 火山引擎(可配置切换) |
|
||||
| 文件存储 | 阿里云 OSS |
|
||||
| 短信服务 | 阿里云 SMS |
|
||||
| 视觉智能 | 阿里云 FaceBody |
|
||||
| 地图/天气 | 高德地图 / 和风天气 |
|
||||
| 音视频 | 火山引擎 RTC |
|
||||
| 部署 | Docker + docker-compose |
|
||||
| API 文档 | drf-yasg(Swagger / 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
|
||||
```
|
||||
@ -32,12 +32,15 @@ class FoodListSerializer(serializers.ModelSerializer):
|
||||
food_type_display = serializers.ReadOnlyField(source='get_food_type_display')
|
||||
is_available = serializers.ReadOnlyField()
|
||||
|
||||
status_display = serializers.ReadOnlyField(source='get_status_display')
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = [
|
||||
'id', 'name', 'food_type', 'food_type_display', 'rarity',
|
||||
'rarity_display', 'image', 'calories', 'is_available',
|
||||
'is_limited', 'created_at'
|
||||
'id', 'name', 'food_type', 'food_type_display', 'rarity',
|
||||
'rarity_display', 'image', 'calories', 'is_available',
|
||||
'is_limited', 'status', 'status_display', 'published_at',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -39,11 +39,11 @@ class FoodViewSet(viewsets.ModelViewSet):
|
||||
def get_queryset(self):
|
||||
"""获取食物列表"""
|
||||
# 管理员可以看到所有食物,普通用户只能看到已发布的食物
|
||||
if self.action in ['list', 'retrieve']:
|
||||
# 对于列表和详情查看,只显示已发布的食物
|
||||
if self.request.user.is_staff or self.request.user.is_superuser:
|
||||
queryset = Food.objects.all()
|
||||
elif self.action in ['list', 'retrieve']:
|
||||
queryset = Food.objects.filter(status='published')
|
||||
else:
|
||||
# 对于创建、更新、删除操作,显示所有食物
|
||||
queryset = Food.objects.all()
|
||||
|
||||
# 筛选参数
|
||||
@ -86,6 +86,57 @@ class FoodViewSet(viewsets.ModelViewSet):
|
||||
'statuses': Food.STATUS_CHOICES
|
||||
})
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="发布食物",
|
||||
responses={
|
||||
200: openapi.Response(description="发布成功"),
|
||||
400: "食物状态不允许发布"
|
||||
}
|
||||
)
|
||||
@action(detail=True, methods=['post'])
|
||||
def publish(self, request, pk=None):
|
||||
"""发布食物"""
|
||||
food = self.get_object()
|
||||
if food.status == 'published':
|
||||
return Response(
|
||||
{'error': '该食物已经是发布状态'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
if food.status == 'archived':
|
||||
return Response(
|
||||
{'error': '已归档的食物不能发布'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
food.publish()
|
||||
serializer = self.get_serializer(food)
|
||||
return Response({
|
||||
'message': f'食物 "{food.name}" 发布成功',
|
||||
'food': serializer.data
|
||||
})
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="归档食物",
|
||||
responses={
|
||||
200: openapi.Response(description="归档成功"),
|
||||
400: "食物状态不允许归档"
|
||||
}
|
||||
)
|
||||
@action(detail=True, methods=['post'])
|
||||
def archive(self, request, pk=None):
|
||||
"""归档食物"""
|
||||
food = self.get_object()
|
||||
if food.status == 'archived':
|
||||
return Response(
|
||||
{'error': '该食物已经是归档状态'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
food.archive()
|
||||
serializer = self.get_serializer(food)
|
||||
return Response({
|
||||
'message': f'食物 "{food.name}" 已归档',
|
||||
'food': serializer.data
|
||||
})
|
||||
|
||||
|
||||
class UserFoodViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
|
||||
@ -376,25 +376,34 @@ LOGGING = {
|
||||
'level': 'INFO',
|
||||
'class': 'common.aliyun_logging.AliyunLogHandler',
|
||||
},
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.StreamHandler',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['aliyun'],
|
||||
'handlers': ['aliyun', 'console'],
|
||||
'level': 'INFO',
|
||||
'propagate': True,
|
||||
},
|
||||
'django.request': {
|
||||
'handlers': ['console'],
|
||||
'level': 'ERROR',
|
||||
'propagate': False,
|
||||
},
|
||||
'aiapp': {
|
||||
'handlers': ['aliyun'],
|
||||
'level': 'INFO', # 例如,为myapp应用设置DEBUG级别的日志记录
|
||||
'handlers': ['aliyun', 'console'],
|
||||
'level': 'INFO',
|
||||
'propagate': True,
|
||||
},
|
||||
'common': {
|
||||
'handlers': ['aliyun'],
|
||||
'handlers': ['aliyun', 'console'],
|
||||
'level': 'INFO',
|
||||
'propagate': True,
|
||||
},
|
||||
'userapp': {
|
||||
'handlers': ['aliyun'],
|
||||
'handlers': ['aliyun', 'console'],
|
||||
'level': 'INFO',
|
||||
'propagate': True,
|
||||
},
|
||||
@ -638,10 +647,10 @@ Cache Configuration:
|
||||
logger.info("="*50)
|
||||
logger.info(db_info)
|
||||
logger.info(f"Database Status: {db_status}")
|
||||
logger.info(f"Database Latency: {db_latency:.2f}ms")
|
||||
logger.info(f"Database Latency: {db_latency if isinstance(db_latency, str) else f'{db_latency:.2f}'}ms")
|
||||
logger.info(cache_info)
|
||||
logger.info(f"Cache Status: {cache_status}")
|
||||
logger.info(f"Cache Latency: {cache_latency:.2f}ms")
|
||||
logger.info(f"Cache Latency: {cache_latency if isinstance(cache_latency, str) else f'{cache_latency:.2f}'}ms")
|
||||
logger.info("="*50)
|
||||
|
||||
# Call on application startup
|
||||
|
||||
@ -8,6 +8,9 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RedisTokenAuthentication(BaseAuthentication):
|
||||
def authenticate_header(self, request):
|
||||
return 'Bearer realm="api"'
|
||||
|
||||
def authenticate(self, request):
|
||||
authorization = request.headers.get('Authorization')
|
||||
if not authorization:
|
||||
|
||||
49
qy_lty/userapp/migrations/0004_affinitylevel_affinityrule.py
Normal file
49
qy_lty/userapp/migrations/0004_affinitylevel_affinityrule.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -11,21 +11,21 @@ class ParadiseUser(AbstractUser):
|
||||
"""
|
||||
phone_number = models.CharField('手机号', max_length=20, unique=True, null=True, blank=True)
|
||||
email = models.EmailField('邮箱', unique=True, null=True, blank=True)
|
||||
|
||||
|
||||
# 新增用户信息字段
|
||||
GENDER_CHOICES = (
|
||||
('M', '男'),
|
||||
('F', '女'),
|
||||
('O', '其他'),
|
||||
)
|
||||
|
||||
|
||||
MBTI_CHOICES = (
|
||||
('INTJ', 'INTJ'), ('INTP', 'INTP'), ('ENTJ', 'ENTJ'), ('ENTP', 'ENTP'),
|
||||
('INFJ', 'INFJ'), ('INFP', 'INFP'), ('ENFJ', 'ENFJ'), ('ENFP', 'ENFP'),
|
||||
('ISTJ', 'ISTJ'), ('ISFJ', 'ISFJ'), ('ESTJ', 'ESTJ'), ('ESFJ', 'ESFJ'),
|
||||
('ISTP', 'ISTP'), ('ISFP', 'ISFP'), ('ESTP', 'ESTP'), ('ESFP', 'ESFP'),
|
||||
)
|
||||
|
||||
|
||||
favorability = models.IntegerField('好感度', default=0)
|
||||
gender = models.CharField('性别', max_length=1, choices=GENDER_CHOICES, null=True, blank=True)
|
||||
resident_city = models.CharField('常驻城市', max_length=50, null=True, blank=True)
|
||||
@ -74,3 +74,41 @@ class ParadiseUser(AbstractUser):
|
||||
# related_name='auth_token',
|
||||
# on_delete=models.CASCADE
|
||||
# )
|
||||
|
||||
|
||||
class AffinityRule(models.Model):
|
||||
"""好感度规则:定义哪些行为可以获得好感度积分"""
|
||||
name = models.CharField('规则名称', max_length=100)
|
||||
description = models.TextField('规则描述', blank=True)
|
||||
points = models.IntegerField('积分', default=0)
|
||||
daily_limit = models.IntegerField('每日上限', null=True, blank=True)
|
||||
is_active = models.BooleanField('已启用', default=True)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '好感度规则'
|
||||
verbose_name_plural = '好感度规则'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class AffinityLevel(models.Model):
|
||||
"""好感度等级:定义不同好感度区间对应的等级和奖励"""
|
||||
level = models.IntegerField('等级', unique=True)
|
||||
name = models.CharField('等级名称', max_length=50)
|
||||
description = models.TextField('等级描述', blank=True)
|
||||
required_points = models.IntegerField('所需积分')
|
||||
rewards = models.JSONField('奖励', default=list)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '好感度等级'
|
||||
verbose_name_plural = '好感度等级'
|
||||
ordering = ['level']
|
||||
|
||||
def __str__(self):
|
||||
return f"Lv{self.level} {self.name}"
|
||||
|
||||
@ -2,10 +2,14 @@ from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import (
|
||||
ParadiseUserViewSet, CustomRegisterView, PhoneLoginView,
|
||||
SendVerifyCodeView, EmailLoginView, UsernameLoginView, MacAddressLoginView
|
||||
SendVerifyCodeView, EmailLoginView, UsernameLoginView, MacAddressLoginView,
|
||||
GroupViewSet, AffinityRuleViewSet, AffinityLevelViewSet
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('groups', GroupViewSet, basename='group')
|
||||
router.register('affinity-rules', AffinityRuleViewSet, basename='affinity-rule')
|
||||
router.register('affinity-levels', AffinityLevelViewSet, basename='affinity-level')
|
||||
router.register('', ParadiseUserViewSet)
|
||||
|
||||
# 用户视图集的登录相关路径
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
from dj_rest_auth.registration.views import RegisterView
|
||||
from rest_framework import viewsets
|
||||
from .models import ParadiseUser
|
||||
from .models import ParadiseUser, AffinityRule, AffinityLevel
|
||||
from device_interaction.models import Device, UserDevice
|
||||
from .serializers import ParadiseUserSerializer, CustomRegisterSerializer, UserInfoSerializer, ProfileUpdateSerializer
|
||||
from rest_framework import viewsets, status
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action, permission_classes, authentication_classes
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
@ -440,6 +441,93 @@ class BindPhoneRequestSchema(serializers.Serializer):
|
||||
phone_number = serializers.CharField(required=True, help_text="手机号码")
|
||||
code = serializers.CharField(required=True, help_text="验证码")
|
||||
|
||||
|
||||
class GroupSerializer(serializers.ModelSerializer):
|
||||
user_count = serializers.SerializerMethodField()
|
||||
permissions = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Group
|
||||
fields = ['id', 'name', 'user_count', 'permissions']
|
||||
|
||||
def get_user_count(self, obj):
|
||||
return obj.paradiseuser_set.count()
|
||||
|
||||
def get_permissions(self, obj):
|
||||
return list(obj.permissions.values_list('codename', flat=True))
|
||||
|
||||
|
||||
class GroupViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
角色(用户组)管理接口
|
||||
仅管理员可访问
|
||||
"""
|
||||
queryset = Group.objects.all()
|
||||
serializer_class = GroupSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return [IsAuthenticated()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_staff:
|
||||
return Group.objects.none()
|
||||
return Group.objects.all()
|
||||
|
||||
|
||||
class AffinityRuleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AffinityRule
|
||||
fields = ['id', 'name', 'description', 'points', 'daily_limit', 'is_active', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class AffinityLevelSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AffinityLevel
|
||||
fields = ['id', 'level', 'name', 'description', 'required_points', 'rewards', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class AffinityRuleViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
好感度规则管理接口
|
||||
仅管理员可写,认证用户可读
|
||||
"""
|
||||
queryset = AffinityRule.objects.all()
|
||||
serializer_class = AffinityRuleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return [IsAuthenticated()]
|
||||
if not self.request.user.is_staff:
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied()
|
||||
return [IsAuthenticated()]
|
||||
|
||||
|
||||
class AffinityLevelViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
好感度等级管理接口
|
||||
仅管理员可写,认证用户可读
|
||||
"""
|
||||
queryset = AffinityLevel.objects.all()
|
||||
serializer_class = AffinityLevelSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return [IsAuthenticated()]
|
||||
if not self.request.user.is_staff:
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied()
|
||||
return [IsAuthenticated()]
|
||||
|
||||
|
||||
class ParadiseUserViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
用户管理接口
|
||||
@ -642,7 +730,18 @@ class AdminEmailLoginView(APIView):
|
||||
# 使用is_admin=True生成管理员专用token
|
||||
token = generate_token(user.id, is_admin=True)
|
||||
logger.info(f"Admin logged in with email: {email}")
|
||||
return success_response(data={'token': token}, message="管理员登录成功")
|
||||
|
||||
# 获取用户角色名称(取第一个分组名称)
|
||||
role_name = "超级管理员" if user.is_superuser else None
|
||||
if not role_name:
|
||||
group = user.groups.first()
|
||||
role_name = group.name if group else "管理员"
|
||||
|
||||
return success_response(data={
|
||||
'token': token,
|
||||
'is_superuser': user.is_superuser,
|
||||
'role': role_name,
|
||||
}, message="管理员登录成功")
|
||||
except ParadiseUser.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user