first commit
This commit is contained in:
commit
0c610c1e49
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
# === General ===
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# === qy-lty-admin (Next.js) ===
|
||||
# dependencies
|
||||
qy-lty-admin/node_modules/
|
||||
# next.js
|
||||
qy-lty-admin/.next/
|
||||
qy-lty-admin/out/
|
||||
# production
|
||||
qy-lty-admin/build/
|
||||
# debug logs
|
||||
qy-lty-admin/npm-debug.log*
|
||||
qy-lty-admin/yarn-debug.log*
|
||||
qy-lty-admin/yarn-error.log*
|
||||
qy-lty-admin/.pnpm-debug.log*
|
||||
# env files
|
||||
# qy-lty-admin/.env
|
||||
# qy-lty-admin/.env.local
|
||||
# qy-lty-admin/.env.development
|
||||
# qy-lty-admin/.env.production
|
||||
# qy-lty-admin/.env.development.local
|
||||
# qy-lty-admin/.env.test.local
|
||||
# qy-lty-admin/.env.production.local
|
||||
# vercel
|
||||
qy-lty-admin/.vercel/
|
||||
# typescript
|
||||
qy-lty-admin/*.tsbuildinfo
|
||||
qy-lty-admin/next-env.d.ts
|
||||
|
||||
# === qy_lty (Django / Python) ===
|
||||
# python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
# env files
|
||||
# qy_lty/.env
|
||||
# logs
|
||||
qy_lty/logs/
|
||||
# static files collected
|
||||
qy_lty/staticfiles/
|
||||
# database
|
||||
*.sqlite3
|
||||
# virtual env
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
# conda
|
||||
environment.lock.yml
|
||||
34
qy-lty-admin/.dockerignore
Normal file
34
qy-lty-admin/.dockerignore
Normal file
@ -0,0 +1,34 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# Node.js
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Next.js
|
||||
# 不再排除.next目录,因为需要将其复制到Docker镜像中
|
||||
# .next
|
||||
out
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
# 不排除生产环境变量,因为在docker-compose中使用
|
||||
# .env.production
|
||||
|
||||
# Misc
|
||||
README.md
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
*.md
|
||||
7
qy-lty-admin/.env.example
Normal file
7
qy-lty-admin/.env.example
Normal file
@ -0,0 +1,7 @@
|
||||
# General environment variables
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api
|
||||
|
||||
# For different environments, use these files:
|
||||
# .env.development - Development environment variables (next dev)
|
||||
# .env.production - Production environment variables (next start)
|
||||
# .env.local - Local overrides (loaded for all environments)
|
||||
2
qy-lty-admin/.env.local.example
Normal file
2
qy-lty-admin/.env.local.example
Normal file
@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api
|
||||
# Add other local-specific variables below
|
||||
35
qy-lty-admin/.gitignore
vendored
Normal file
35
qy-lty-admin/.gitignore
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
# but keep the examples
|
||||
!.env.example
|
||||
!.env.local.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
52
qy-lty-admin/Dockerfile
Normal file
52
qy-lty-admin/Dockerfile
Normal file
@ -0,0 +1,52 @@
|
||||
# 构建阶段
|
||||
FROM node:22.10.0-alpine AS builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置yarn镜像源为淘宝镜像
|
||||
RUN yarn config set registry https://registry.npmmirror.com && \
|
||||
yarn config set disturl https://npmmirror.com/dist --global
|
||||
|
||||
# 复制package.json和yarn.lock
|
||||
COPY package.json yarn.lock* ./
|
||||
|
||||
# 安装依赖(包括devDependencies用于构建)
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN yarn build
|
||||
|
||||
# 运行阶段
|
||||
FROM node:22.10.0-alpine AS runner
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置yarn镜像源为淘宝镜像
|
||||
RUN yarn config set registry https://registry.npmmirror.com && \
|
||||
yarn config set disturl https://npmmirror.com/dist --global
|
||||
|
||||
# 复制package.json和yarn.lock
|
||||
COPY package.json yarn.lock* ./
|
||||
|
||||
# 仅安装生产依赖
|
||||
RUN yarn install --production --frozen-lockfile
|
||||
|
||||
# 设置环境变量
|
||||
ENV NODE_ENV=production
|
||||
ENV PATH=/app/node_modules/.bin:$PATH
|
||||
|
||||
# 从构建阶段复制构建产物
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/next.config.mjs ./
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3000
|
||||
|
||||
# 启动应用
|
||||
CMD ["yarn", "start"]
|
||||
137
qy-lty-admin/README.md
Normal file
137
qy-lty-admin/README.md
Normal file
@ -0,0 +1,137 @@
|
||||
# 洛天依应用管理后台
|
||||
|
||||
这是一个基于 Next.js 和 React 构建的洛天依应用管理后台系统,提供完整的管理功能,包括用户管理、AI模型管理、卡牌管理、内容管理等功能。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **用户管理**:查看用户数据、活跃用户统计
|
||||
- **AI模型管理**:大模型框架系统、模型微调、语音克隆与合成、知识库管理
|
||||
- **卡牌管理**:服装卡牌、道具卡牌、家居装饰卡牌、食物卡牌
|
||||
- **内容管理**:歌曲管理、舞蹈管理、好感度系统、成就系统
|
||||
- **数据分析**:用户活跃度统计、系统运行概览
|
||||
- **安全认证**:用户登录/注册系统
|
||||
|
||||
## 技术栈
|
||||
|
||||
- [Next.js](https://nextjs.org/) - React 框架
|
||||
- [React](https://reactjs.org/) - UI 库
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - 样式框架
|
||||
- [Radix UI](https://www.radix-ui.com/) - 无障碍组件库
|
||||
- [Lucide React](https://lucide.dev/) - 图标库
|
||||
- [Recharts](https://recharts.org/) - 图表库
|
||||
|
||||
## 安装与运行
|
||||
|
||||
### 前提条件
|
||||
|
||||
- Node.js 22.x 或更高版本
|
||||
- npm 或 yarn 或 pnpm
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. 克隆仓库
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd admin-dashboard
|
||||
```
|
||||
|
||||
2. 安装依赖
|
||||
```bash
|
||||
npm install
|
||||
# 或
|
||||
yarn
|
||||
# 或
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. 启动开发服务器
|
||||
```bash
|
||||
npm run dev
|
||||
# 或
|
||||
yarn dev
|
||||
# 或
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
4. 打开浏览器访问 http://localhost:3000
|
||||
|
||||
## 构建与部署
|
||||
|
||||
```bash
|
||||
# 构建项目
|
||||
npm run build
|
||||
# 或
|
||||
yarn build
|
||||
# 或
|
||||
pnpm build
|
||||
|
||||
# 启动生产环境服务器
|
||||
npm run start
|
||||
# 或
|
||||
yarn start
|
||||
# 或
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
admin-dashboard/
|
||||
├── app/ # Next.js 应用程序目录
|
||||
│ ├── ai-model/ # AI模型管理相关页面
|
||||
│ ├── achievements/ # 成就系统页面
|
||||
│ ├── affinity/ # 好感度系统页面
|
||||
│ ├── dances/ # 舞蹈管理页面
|
||||
│ ├── food/ # 食物卡牌管理页面
|
||||
│ ├── home-decor/ # 家居装饰卡牌管理页面
|
||||
│ ├── login/ # 登录页面
|
||||
│ ├── outfits/ # 服装卡牌管理页面
|
||||
│ ├── permissions/ # 权限管理页面
|
||||
│ ├── props/ # 道具卡牌管理页面
|
||||
│ ├── register/ # 注册页面
|
||||
│ ├── settings/ # 系统设置页面
|
||||
│ ├── songs/ # 歌曲管理页面
|
||||
│ └── users/ # 用户管理页面
|
||||
├── components/ # React 组件
|
||||
├── hooks/ # 自定义 React hooks
|
||||
├── lib/ # 工具函数和辅助库
|
||||
├── public/ # 静态资源
|
||||
└── styles/ # 全局样式
|
||||
```
|
||||
|
||||
## 浏览器支持
|
||||
|
||||
- Chrome (最新版本)
|
||||
- Firefox (最新版本)
|
||||
- Safari (最新版本)
|
||||
- Edge (最新版本)
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
This project uses environment variables for configuration across different environments. The configuration files are:
|
||||
|
||||
- `.env` - Base environment variables (lowest priority)
|
||||
- `.env.development` - Development environment variables (used with `next dev`)
|
||||
- `.env.production` - Production environment variables (used with `next start`)
|
||||
- `.env.local` - Local overrides for any environment (highest priority)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Copy the example files to create your environment configuration:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cp .env.local.example .env.local
|
||||
```
|
||||
|
||||
2. Update the variables in these files as needed for your environment.
|
||||
|
||||
3. Environment variables that need to be exposed to the browser should be prefixed with `NEXT_PUBLIC_`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `NEXT_PUBLIC_API_BASE_URL`: The base URL for API requests
|
||||
48
qy-lty-admin/app/achievements/loading.tsx
Normal file
48
qy-lty-admin/app/achievements/loading.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Trophy } from "lucide-react"
|
||||
|
||||
export default function AchievementsLoading() {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="成就管理" text="创建和管理系统成就">
|
||||
<Button disabled className="bg-gradient-to-r from-amber-500 to-orange-600">
|
||||
<Trophy className="mr-2 h-4 w-4" />
|
||||
添加成就
|
||||
</Button>
|
||||
</DashboardHeader>
|
||||
<div className="grid gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 w-24 ml-auto" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<Card key={i} className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-1 flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
469
qy-lty-admin/app/achievements/page.tsx
Normal file
469
qy-lty-admin/app/achievements/page.tsx
Normal file
@ -0,0 +1,469 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import {
|
||||
Award,
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
Trash2,
|
||||
Trophy,
|
||||
Filter,
|
||||
ArrowUpDown,
|
||||
Zap,
|
||||
Heart,
|
||||
Coins,
|
||||
Gift,
|
||||
Shirt,
|
||||
BadgeCheck,
|
||||
} from "lucide-react"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { AddAchievementDialog } from "@/components/achievements/add-achievement-dialog"
|
||||
import { AchievementDetailDialog } from "@/components/achievements/achievement-detail-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import type { Achievement } from "@/lib/api/types"
|
||||
|
||||
export default function AchievementsPage() {
|
||||
const [achievements, setAchievements] = useState<Achievement[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [activeTab, setActiveTab] = useState("all")
|
||||
|
||||
useEffect(() => {
|
||||
fetchAchievements()
|
||||
}, [])
|
||||
|
||||
const fetchAchievements = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 这里应该是实际的API调用
|
||||
// const response = await fetch('/api/achievements')
|
||||
// const data = await response.json()
|
||||
|
||||
// 使用模拟数据
|
||||
const mockData = [
|
||||
{
|
||||
id: "1",
|
||||
name: "初次见面",
|
||||
description: "第一次与洛天依对话",
|
||||
icon: "message-circle",
|
||||
category: "互动",
|
||||
requirement: "与洛天依进行第一次对话",
|
||||
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",
|
||||
},
|
||||
]
|
||||
|
||||
setAchievements(mockData)
|
||||
} catch (error) {
|
||||
console.error("获取成就列表失败:", error)
|
||||
toast({
|
||||
title: "获取失败",
|
||||
description: "获取成就列表时发生错误",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAchievement = async (id: string) => {
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 更新本地状态
|
||||
setAchievements((prev) => prev.filter((achievement) => achievement.id !== id))
|
||||
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "成就已成功删除",
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: "删除成就时发生错误",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case "互动":
|
||||
return "bg-blue-500"
|
||||
case "好感度":
|
||||
return "bg-pink-500"
|
||||
case "收集":
|
||||
return "bg-purple-500"
|
||||
case "探索":
|
||||
return "bg-green-500"
|
||||
case "特殊":
|
||||
return "bg-amber-500"
|
||||
case "隐藏":
|
||||
return "bg-gray-500"
|
||||
default:
|
||||
return "bg-gray-500"
|
||||
}
|
||||
}
|
||||
|
||||
const getRewardIcon = (rewardType: string) => {
|
||||
switch (rewardType) {
|
||||
case "经验值":
|
||||
return <Zap className="h-4 w-4 text-blue-500" />
|
||||
case "好感度":
|
||||
return <Heart className="h-4 w-4 text-pink-500" />
|
||||
case "虚拟币":
|
||||
return <Coins className="h-4 w-4 text-amber-500" />
|
||||
case "道具":
|
||||
return <Gift className="h-4 w-4 text-purple-500" />
|
||||
case "服装":
|
||||
return <Shirt className="h-4 w-4 text-teal-500" />
|
||||
case "称号":
|
||||
return <BadgeCheck className="h-4 w-4 text-indigo-500" />
|
||||
default:
|
||||
return <Gift className="h-4 w-4 text-gray-500" />
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤成就
|
||||
const filteredAchievements = achievements.filter((achievement) => {
|
||||
// 搜索过滤
|
||||
const matchesSearch =
|
||||
searchQuery === "" ||
|
||||
achievement.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
achievement.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
// 标签过滤
|
||||
const matchesTab =
|
||||
activeTab === "all" || (activeTab === "hidden" && achievement.isHidden) || achievement.category === activeTab
|
||||
|
||||
return matchesSearch && matchesTab
|
||||
})
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="成就管理" text="创建和管理系统成就">
|
||||
<AddAchievementDialog onAchievementAdded={fetchAchievements} />
|
||||
</DashboardHeader>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
placeholder="搜索成就..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="flex gap-1">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>筛选</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setActiveTab("all")}>全部成就</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setActiveTab("互动")}>互动成就</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setActiveTab("好感度")}>好感度成就</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setActiveTab("收集")}>收集成就</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setActiveTab("探索")}>探索成就</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setActiveTab("特殊")}>特殊成就</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setActiveTab("hidden")}>隐藏成就</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="flex gap-1">
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
<span>排序</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>按名称排序</DropdownMenuItem>
|
||||
<DropdownMenuItem>按解锁率排序</DropdownMenuItem>
|
||||
<DropdownMenuItem>按创建日期排序</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="互动">互动</TabsTrigger>
|
||||
<TabsTrigger value="好感度">好感度</TabsTrigger>
|
||||
<TabsTrigger value="收集">收集</TabsTrigger>
|
||||
<TabsTrigger value="探索">探索</TabsTrigger>
|
||||
<TabsTrigger value="特殊">特殊</TabsTrigger>
|
||||
<TabsTrigger value="hidden">隐藏</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={activeTab} className="mt-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{loading ? (
|
||||
// 加载状态
|
||||
Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<Card key={i} className="p-4 space-y-3 animate-pulse">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-200"></div>
|
||||
<div className="space-y-1 flex-1">
|
||||
<div className="h-4 w-3/4 bg-gray-200 rounded"></div>
|
||||
<div className="h-3 w-1/2 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-16 w-full bg-gray-200 rounded"></div>
|
||||
<div className="flex justify-between">
|
||||
<div className="h-8 w-20 bg-gray-200 rounded"></div>
|
||||
<div className="h-8 w-20 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
) : filteredAchievements.length > 0 ? (
|
||||
filteredAchievements.map((achievement) => (
|
||||
<Card
|
||||
key={achievement.id}
|
||||
className="overflow-hidden border-none shadow-md hover:shadow-lg transition-all"
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 rounded-full bg-amber-100 flex items-center justify-center">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">{achievement.name}</h3>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Badge className={getCategoryColor(achievement.category)}>{achievement.category}</Badge>
|
||||
{achievement.isHidden && <Badge className="bg-gray-500">隐藏</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DeleteConfirmationDialog
|
||||
title="删除成就"
|
||||
description={`确定要删除成就 "${achievement.name}" 吗?此操作不可撤销。`}
|
||||
onConfirm={() => handleDeleteAchievement(achievement.id)}
|
||||
trigger={
|
||||
<div className="flex items-center text-red-500">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-3">{achievement.description}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>解锁率</span>
|
||||
<span>{achievement.unlockRate}%</span>
|
||||
</div>
|
||||
<Progress value={achievement.unlockRate} className="h-2" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<span>奖励:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{getRewardIcon(achievement.rewardType)}
|
||||
<span>
|
||||
{achievement.rewardAmount} {achievement.rewardType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AchievementDetailDialog achievement={achievement} onEdit={() => {}} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-8 text-center">
|
||||
<Award className="h-12 w-12 text-gray-300 mb-2" />
|
||||
<h3 className="text-lg font-medium">未找到成就</h3>
|
||||
<p className="text-gray-500">没有符合当前筛选条件的成就</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
1005
qy-lty-admin/app/affinity/page.tsx
Normal file
1005
qy-lty-admin/app/affinity/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
445
qy-lty-admin/app/ai-model/page.tsx
Normal file
445
qy-lty-admin/app/ai-model/page.tsx
Normal file
@ -0,0 +1,445 @@
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Brain, Mic, Database, Plus, Sparkles, Edit, Play, Sliders, User } from "lucide-react"
|
||||
|
||||
export default function AIModelPage() {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="大模型管理" text="管理洛天依的AI模型、语音和知识库">
|
||||
<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>
|
||||
</DashboardHeader>
|
||||
|
||||
<Tabs defaultValue="framework" className="space-y-4">
|
||||
<TabsList className="bg-white p-1 shadow-md rounded-lg border">
|
||||
<TabsTrigger
|
||||
value="framework"
|
||||
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="finetune"
|
||||
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="voice"
|
||||
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="knowledge"
|
||||
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="framework" 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 className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="border-2 border-pink-500 shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-pink-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 -z-10"></div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">当前使用模型</CardTitle>
|
||||
<div className="p-1.5 rounded-full bg-pink-100">
|
||||
<Brain className="h-5 w-5 text-pink-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-pink-600 to-purple-600">
|
||||
GPT-4o
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">版本: 2.0 | 上次更新: 2024-03-15</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-pink-200 hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-md hover:shadow-lg transition-all duration-300 overflow-hidden relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-purple-500/5 to-blue-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 -z-10"></div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">备用模型</CardTitle>
|
||||
<div className="p-1.5 rounded-full bg-purple-100">
|
||||
<Brain className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl font-bold">Claude 3</div>
|
||||
<p className="text-xs text-muted-foreground">版本: 1.5 | 上次更新: 2024-02-20</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full hover:bg-purple-50 hover:text-purple-700 transition-all duration-200"
|
||||
>
|
||||
设为主要
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-md hover:shadow-lg transition-all duration-300 overflow-hidden relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-teal-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 -z-10"></div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">备用模型</CardTitle>
|
||||
<div className="p-1.5 rounded-full bg-blue-100">
|
||||
<Brain className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl font-bold">Llama 3</div>
|
||||
<p className="text-xs text-muted-foreground">版本: 1.0 | 上次更新: 2024-01-10</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full hover:bg-blue-50 hover:text-blue-700 transition-all duration-200"
|
||||
>
|
||||
设为主要
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<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>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="finetune" 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 className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<Card className="shadow-md hover:shadow-lg transition-all duration-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center">
|
||||
<Sparkles className="h-4 w-4 mr-2 text-pink-500" />
|
||||
人格设定
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1 bg-pink-50 p-3 rounded-lg">
|
||||
<p className="text-sm font-medium text-pink-700">性格特点</p>
|
||||
<p className="text-sm text-gray-600">活泼、开朗、善良、有音乐天赋</p>
|
||||
</div>
|
||||
<div className="space-y-1 bg-purple-50 p-3 rounded-lg">
|
||||
<p className="text-sm font-medium text-purple-700">语言风格</p>
|
||||
<p className="text-sm text-gray-600">亲切、自然、略带俏皮</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1 bg-blue-50 p-3 rounded-lg">
|
||||
<p className="text-sm font-medium text-blue-700">兴趣爱好</p>
|
||||
<p className="text-sm text-gray-600">唱歌、跳舞、与用户交流</p>
|
||||
</div>
|
||||
<div className="space-y-1 bg-teal-50 p-3 rounded-lg">
|
||||
<p className="text-sm font-medium text-teal-700">背景设定</p>
|
||||
<p className="text-sm text-gray-600">虚拟歌手、16岁、身高158cm</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑人格设定
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-md hover:shadow-lg transition-all duration-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center">
|
||||
<Database className="h-4 w-4 mr-2 text-purple-500" />
|
||||
微调数据集
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-2 rounded-lg bg-gradient-to-r from-pink-50 to-purple-50">
|
||||
<p className="text-sm font-medium">基础对话数据集</p>
|
||||
<p className="text-sm text-pink-600 font-medium">10,000 条对话</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">歌曲相关对话</p>
|
||||
<p className="text-sm text-purple-600 font-medium">2,500 条对话</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">舞蹈相关对话</p>
|
||||
<p className="text-sm text-blue-600 font-medium">1,800 条对话</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">服装相关对话</p>
|
||||
<p className="text-sm text-teal-600 font-medium">3,200 条对话</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-purple-50 hover:text-purple-700 transition-all duration-200"
|
||||
>
|
||||
查看数据集
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300"
|
||||
>
|
||||
添加新数据
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="voice" 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 className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="shadow-md hover:shadow-lg transition-all duration-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center">
|
||||
<Mic className="h-4 w-4 mr-2 text-pink-500" />
|
||||
语音模型
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-2 rounded-lg bg-gradient-to-r from-pink-50 to-purple-50">
|
||||
<p className="text-sm font-medium">当前模型</p>
|
||||
<p className="text-sm text-pink-600 font-medium">洛天依官方语音 v3.0</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">采样率</p>
|
||||
<p className="text-sm text-gray-600">48kHz</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">音质</p>
|
||||
<p className="text-sm text-gray-600">高清 (24bit)</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">上次更新</p>
|
||||
<p className="text-sm text-gray-600">2024-03-01</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
试听示例
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-md hover:shadow-lg transition-all duration-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center">
|
||||
<Sliders className="h-4 w-4 mr-2 text-purple-500" />
|
||||
语音参数
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">音调</p>
|
||||
<p className="text-sm text-gray-600">标准 (0)</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">语速</p>
|
||||
<p className="text-sm text-gray-600">中等 (1.0x)</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">情感强度</p>
|
||||
<p className="text-sm text-gray-600">中等 (0.7)</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">音量</p>
|
||||
<p className="text-sm text-gray-600">标准 (1.0)</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full hover:bg-purple-50 hover:text-purple-700 transition-all duration-200"
|
||||
>
|
||||
调整参数
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200">
|
||||
上传新语音样本
|
||||
</Button>
|
||||
<Button className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg">
|
||||
更新语音模型
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="knowledge" 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 className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="shadow-md hover:shadow-lg transition-all duration-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center">
|
||||
<User className="h-4 w-4 mr-2 text-pink-500" />
|
||||
用户模型
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-2 rounded-lg bg-gradient-to-r from-pink-50 to-purple-50">
|
||||
<p className="text-sm font-medium">活跃用户模型</p>
|
||||
<p className="text-sm text-pink-600 font-medium">5,732 个</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">平均模型大小</p>
|
||||
<p className="text-sm text-gray-600">2.4 MB</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">上次更新</p>
|
||||
<p className="text-sm text-gray-600">今天 08:45</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
|
||||
>
|
||||
查看用户模型
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-md hover:shadow-lg transition-all duration-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center">
|
||||
<Database className="h-4 w-4 mr-2 text-purple-500" />
|
||||
基础知识库
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">洛天依相关知识</p>
|
||||
<p className="text-sm text-purple-600 font-medium">1,200 条</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">歌曲知识</p>
|
||||
<p className="text-sm text-blue-600 font-medium">850 条</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">舞蹈知识</p>
|
||||
<p className="text-sm text-teal-600 font-medium">320 条</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<p className="text-sm font-medium">通用知识</p>
|
||||
<p className="text-sm text-gray-600">5,000+ 条</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full hover:bg-purple-50 hover:text-purple-700 transition-all duration-200"
|
||||
>
|
||||
编辑知识库
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200">
|
||||
导入知识
|
||||
</Button>
|
||||
<Button className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg">
|
||||
更新知识库
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
122
qy-lty-admin/app/dances/[id]/loading.tsx
Normal file
122
qy-lty-admin/app/dances/[id]/loading.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
export default function DanceDetailLoading() {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="舞蹈详情" text="加载中...">
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回
|
||||
</Button>
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
<Skeleton className="h-6 w-[200px]" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="aspect-video w-full" />
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
<Skeleton className="h-6 w-[100px]" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Skeleton className="h-4 w-[150px]" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-px w-full" />
|
||||
|
||||
<div>
|
||||
<Skeleton className="h-4 w-[80px] mb-2" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Skeleton className="h-4 w-[80px] mb-2" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Skeleton className="h-4 w-[80px] mb-2" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="details" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details" disabled>
|
||||
详情
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="motion" disabled>
|
||||
动作管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="related" disabled>
|
||||
相关舞蹈
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Skeleton className="h-6 w-[100px]" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Skeleton className="h-6 w-[100px]" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
466
qy-lty-admin/app/dances/[id]/page.tsx
Normal file
466
qy-lty-admin/app/dances/[id]/page.tsx
Normal file
@ -0,0 +1,466 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { AddDanceDialog } from "@/components/dances/add-dance-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { ArrowLeft, Download, Edit, Play, Upload, FileText, AlertTriangle } from "lucide-react"
|
||||
import type { Dance } from "@/lib/api/types"
|
||||
|
||||
// 模拟获取舞蹈详情的API
|
||||
const getDanceById = async (id: string): Promise<Dance | null> => {
|
||||
// 模拟API延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
// 模拟舞蹈数据
|
||||
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",
|
||||
status: "已发布",
|
||||
activatedCount: 1245,
|
||||
printedCount: 2000,
|
||||
},
|
||||
{
|
||||
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",
|
||||
status: "已发布",
|
||||
activatedCount: 1823,
|
||||
printedCount: 3000,
|
||||
},
|
||||
{
|
||||
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",
|
||||
status: "已发布",
|
||||
activatedCount: 1356,
|
||||
printedCount: 2500,
|
||||
},
|
||||
{
|
||||
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",
|
||||
status: "已发布",
|
||||
activatedCount: 1578,
|
||||
printedCount: 3000,
|
||||
},
|
||||
{
|
||||
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",
|
||||
status: "未发布",
|
||||
activatedCount: 0,
|
||||
printedCount: 2000,
|
||||
},
|
||||
]
|
||||
|
||||
const dance = mockDances.find((dance) => dance.id === id)
|
||||
return dance || null
|
||||
}
|
||||
|
||||
export default function DanceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [dance, setDance] = useState<Dance | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
|
||||
const id = params.id as string
|
||||
|
||||
useEffect(() => {
|
||||
const loadDance = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await getDanceById(id)
|
||||
setDance(data)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "加载失败",
|
||||
description: "无法加载舞蹈详情,请重试。",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadDance()
|
||||
}, [id, toast])
|
||||
|
||||
const handleEditDance = (updatedDance: Dance) => {
|
||||
setDance(updatedDance)
|
||||
toast({
|
||||
title: "更新成功",
|
||||
description: `舞蹈 ${updatedDance.name} 已成功更新`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteDance = async () => {
|
||||
// 模拟删除API
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: `舞蹈 ${dance?.name} 已成功删除`,
|
||||
variant: "destructive",
|
||||
})
|
||||
|
||||
// 返回舞蹈列表页
|
||||
router.push("/dances")
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="舞蹈详情" text="加载中...">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回
|
||||
</Button>
|
||||
</DashboardHeader>
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<div className="h-6 w-[200px] bg-gray-200 rounded animate-pulse"></div>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="h-4 w-[300px] bg-gray-200 rounded animate-pulse"></div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px] bg-gray-200 rounded animate-pulse"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!dance) {
|
||||
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为 {id} 的舞蹈</p>
|
||||
<Button asChild>
|
||||
<a href="/dances">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回舞蹈列表
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
const isPublished = dance.status === "已发布"
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<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>
|
||||
<a href="/dances">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回列表
|
||||
</a>
|
||||
</Button>
|
||||
<DashboardHeader heading={dance.name} text={`舞蹈ID: ${dance.id}`}>
|
||||
<div className="flex space-x-2 ml-auto">
|
||||
{!isPublished && (
|
||||
<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"
|
||||
>
|
||||
<a href={`/dances/edit/${params.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑舞蹈
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// 模拟下载动作文件
|
||||
toast({
|
||||
title: "开始下载",
|
||||
description: `正在下载舞蹈动作文件`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出动作文件
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="details" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">舞蹈详情</TabsTrigger>
|
||||
<TabsTrigger value="batches">批次管理</TabsTrigger>
|
||||
<TabsTrigger value="analytics">数据分析</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1 border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">舞蹈预览</CardTitle>
|
||||
</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 group">
|
||||
<img
|
||||
src={dance.coverUrl || "/placeholder.svg?height=300&width=300"}
|
||||
alt={dance.name}
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer">
|
||||
<Play className="h-16 w-16 text-white" />
|
||||
</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"}`}>{dance.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">舞蹈详情</CardTitle>
|
||||
</CardHeader>
|
||||
<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">{dance.choreographer}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">分类</p>
|
||||
<p className="font-medium">{dance.category}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">时长</p>
|
||||
<p className="font-medium">{dance.duration}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">难度</p>
|
||||
<p className="font-medium">{dance.difficulty}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">激活数量</p>
|
||||
<p className="font-medium">{dance.activatedCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">印刷总数</p>
|
||||
<p className="font-medium">{dance.printedCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">剩余库存</p>
|
||||
<p className="font-medium">{dance.printedCount - dance.activatedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">标签</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dance.tags?.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="bg-purple-50 text-purple-700">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p className="text-gray-700">{dance.description || "暂无描述"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batches" className="space-y-4">
|
||||
<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>
|
||||
<CardDescription>管理舞蹈卡牌的印刷批次和卡牌ID</CardDescription>
|
||||
</div>
|
||||
<Button className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
添加批次
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<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-right text-sm font-medium text-gray-500">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 text-sm font-medium">B001</td>
|
||||
<td className="py-3 px-4 text-sm">2023-04-01</td>
|
||||
<td className="py-3 px-4 text-sm">1000</td>
|
||||
<td className="py-3 px-4 text-sm font-mono text-xs">DNC{dance.id}-0001</td>
|
||||
<td className="py-3 px-4 text-sm font-mono text-xs">DNC{dance.id}-1000</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>
|
||||
{isPublished && (
|
||||
<tr className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4 text-sm font-medium">B002</td>
|
||||
<td className="py-3 px-4 text-sm">2023-08-15</td>
|
||||
<td className="py-3 px-4 text-sm">1000</td>
|
||||
<td className="py-3 px-4 text-sm font-mono text-xs">DNC{dance.id}-1001</td>
|
||||
<td className="py-3 px-4 text-sm font-mono text-xs">DNC{dance.id}-2000</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>
|
||||
)}
|
||||
</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">
|
||||
<CardHeader>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 编辑舞蹈对话框 */}
|
||||
<AddDanceDialog
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
onDanceAdded={handleEditDance}
|
||||
editDance={dance}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<DeleteConfirmationDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDeleteDance}
|
||||
title="删除舞蹈"
|
||||
description={`确定要删除舞蹈 "${dance.name}" 吗?此操作无法撤销。`}
|
||||
/>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
38
qy-lty-admin/app/dances/loading.tsx
Normal file
38
qy-lty-admin/app/dances/loading.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export default function DancesLoading() {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="舞蹈管理" text="管理洛天依的舞蹈库">
|
||||
<Skeleton className="h-10 w-[120px]" />
|
||||
</DashboardHeader>
|
||||
<div className="flex items-center space-y-2 mb-6">
|
||||
<Skeleton className="h-10 w-[300px]" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-[200px]" />
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
<div className="flex space-x-2">
|
||||
<Skeleton className="h-8 w-[80px]" />
|
||||
<Skeleton className="h-8 w-[80px]" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
352
qy-lty-admin/app/dances/page.tsx
Normal file
352
qy-lty-admin/app/dances/page.tsx
Normal file
@ -0,0 +1,352 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Edit, Video, Eye, Plus, Trash2 } from "lucide-react"
|
||||
import { AddDanceDialog } from "@/components/dances/add-dance-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import type { Dance } from "@/lib/api/types"
|
||||
|
||||
// 初始舞蹈数据
|
||||
const initialDances: 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",
|
||||
},
|
||||
]
|
||||
|
||||
export default function DancesPage() {
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const [dances, setDances] = useState<Dance[]>(initialDances)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [danceToEdit, setDanceToEdit] = useState<Dance | undefined>(undefined)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [danceToDelete, setDanceToDelete] = useState<Dance | null>(null)
|
||||
|
||||
const itemsPerPage = 5
|
||||
|
||||
// 过滤和分页
|
||||
const filteredDances = dances.filter(
|
||||
(dance) =>
|
||||
dance.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
dance.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(dance.choreographer && dance.choreographer.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(dance.category && dance.category.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(dance.tags && dance.tags.some((tag) => tag.toLowerCase().includes(searchTerm.toLowerCase()))),
|
||||
)
|
||||
|
||||
const totalPages = Math.ceil(filteredDances.length / itemsPerPage)
|
||||
const paginatedDances = filteredDances.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
|
||||
// 处理添加舞蹈
|
||||
const handleAddDance = (newDance: Dance) => {
|
||||
setDances((prevDances) => [...prevDances, newDance])
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: `舞蹈 ${newDance.name} 已成功添加`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理编辑舞蹈
|
||||
const handleEditDance = (updatedDance: Dance) => {
|
||||
setDances((prevDances) => prevDances.map((dance) => (dance.id === updatedDance.id ? updatedDance : dance)))
|
||||
setDanceToEdit(undefined)
|
||||
setIsAddDialogOpen(false)
|
||||
toast({
|
||||
title: "更新成功",
|
||||
description: `舞蹈 ${updatedDance.name} 已成功更新`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理删除舞蹈
|
||||
const handleDeleteDance = () => {
|
||||
if (!danceToDelete) return
|
||||
|
||||
setDances((prevDances) => prevDances.filter((dance) => dance.id !== danceToDelete.id))
|
||||
setDanceToDelete(null)
|
||||
setIsDeleteDialogOpen(false)
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: `舞蹈 ${danceToDelete.name} 已成功删除`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEditDialog = (dance: Dance) => {
|
||||
setDanceToEdit(dance)
|
||||
setIsAddDialogOpen(true)
|
||||
}
|
||||
|
||||
// 查看舞蹈详情
|
||||
const viewDanceDetails = (dance: Dance) => {
|
||||
router.push(`/dances/${dance.id}`)
|
||||
}
|
||||
|
||||
// 打开删除确认对话框
|
||||
const openDeleteDialog = (dance: Dance) => {
|
||||
setDanceToDelete(dance)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="舞蹈管理" text="管理洛天依的舞蹈库">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDanceToEdit(undefined)
|
||||
setIsAddDialogOpen(true)
|
||||
}}
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加舞蹈
|
||||
</Button>
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="flex items-center justify-between space-y-2 mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索舞蹈..."
|
||||
className="w-[300px] pl-8 border-none bg-white shadow-md focus-visible:ring-purple-500"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value)
|
||||
setCurrentPage(1) // 重置到第一页
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-[50px]"></TableHead>
|
||||
<TableHead className="w-[80px]">ID</TableHead>
|
||||
<TableHead>舞蹈名称</TableHead>
|
||||
<TableHead>编舞者</TableHead>
|
||||
<TableHead>难度</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>时长</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedDances.map((dance) => (
|
||||
<TableRow key={dance.id} className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell>
|
||||
<div className="h-8 w-8 rounded-full bg-purple-100 flex items-center justify-center text-purple-600">
|
||||
<Video className="h-4 w-4" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{dance.id}</TableCell>
|
||||
<TableCell className="font-medium text-purple-600">{dance.name}</TableCell>
|
||||
<TableCell>{dance.choreographer || "未知"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
dance.difficulty === "初级"
|
||||
? "bg-green-100 text-green-800"
|
||||
: dance.difficulty === "中等"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: dance.difficulty === "中高级"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: dance.difficulty === "高级"
|
||||
? "bg-orange-100 text-orange-800"
|
||||
: dance.difficulty === "专业"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}
|
||||
>
|
||||
{dance.difficulty || "未知"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{dance.category || "未分类"}</TableCell>
|
||||
<TableCell>{dance.duration || "未知"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-purple-50 hover:text-purple-600"
|
||||
onClick={() => viewDanceDetails(dance)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">查看详情</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-purple-50 hover:text-purple-600"
|
||||
onClick={() => openEditDialog(dance)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">编辑</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-red-50 hover:text-red-600"
|
||||
onClick={() => openDeleteDialog(dance)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">删除</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{paginatedDances.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">
|
||||
显示 {paginatedDances.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
|
||||
{Math.min(currentPage * itemsPerPage, filteredDances.length)} 共 {filteredDances.length} 个舞蹈
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-purple-50 hover:text-purple-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-purple-50 hover:text-purple-700 transition-all duration-200"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* 添加/编辑舞蹈对话框 */}
|
||||
<AddDanceDialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={setIsAddDialogOpen}
|
||||
onDanceAdded={danceToEdit ? handleEditDance : handleAddDance}
|
||||
editDance={danceToEdit}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<DeleteConfirmationDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDeleteDance}
|
||||
title="删除舞蹈"
|
||||
description={`确定要删除舞蹈 "${danceToDelete?.name}" 吗?此操作无法撤销。`}
|
||||
/>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
65
qy-lty-admin/app/food/[id]/loading.tsx
Normal file
65
qy-lty-admin/app/food/[id]/loading.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
export default function FoodDetailLoading() {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex items-center mb-6">
|
||||
<Button variant="ghost" size="sm" className="mr-4" disabled>
|
||||
<Skeleton className="h-4 w-4 mr-2" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</Button>
|
||||
<DashboardHeader heading={<Skeleton className="h-8 w-48" />} text={<Skeleton className="h-5 w-32" />}>
|
||||
<div className="flex space-x-2">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="details" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">食物详情</TabsTrigger>
|
||||
<TabsTrigger value="batches">批次管理</TabsTrigger>
|
||||
<TabsTrigger value="analytics">数据分析</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<Skeleton className="w-full aspect-square max-w-[300px] rounded-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
))}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
299
qy-lty-admin/app/food/[id]/page.tsx
Normal file
299
qy-lty-admin/app/food/[id]/page.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
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, Loader2 } from "lucide-react"
|
||||
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 { getFood } from "@/lib/api/food"
|
||||
import type { Food } from "@/components/food/food-detail-dialog"
|
||||
|
||||
// 扩展Food类型以包含批次相关信息
|
||||
type FoodWithBatches = Food & {
|
||||
printedCount?: number
|
||||
batches?: Array<{
|
||||
id: string
|
||||
date: string
|
||||
quantity: number
|
||||
startId: string
|
||||
endId: string
|
||||
activatedCount: number
|
||||
}>
|
||||
}
|
||||
|
||||
export default function FoodDetailPage({ params }: { params: { id: string } }) {
|
||||
const { toast } = useToast()
|
||||
const [food, setFood] = useState<FoodWithBatches | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// 获取食物详情
|
||||
const fetchFoodDetail = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await getFood(params.id)
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 为演示目的,添加一些模拟的批次数据
|
||||
const foodWithBatches: FoodWithBatches = {
|
||||
...response.data,
|
||||
printedCount: response.data.activatedCount + Math.floor(Math.random() * 1000),
|
||||
batches: [
|
||||
{
|
||||
id: "B001",
|
||||
date: "2023-08-01",
|
||||
quantity: 2000,
|
||||
startId: `${response.data.id}-0001`,
|
||||
endId: `${response.data.id}-2000`,
|
||||
activatedCount: Math.floor(response.data.activatedCount * 0.6),
|
||||
},
|
||||
{
|
||||
id: "B002",
|
||||
date: "2023-11-15",
|
||||
quantity: 1000,
|
||||
startId: `${response.data.id}-2001`,
|
||||
endId: `${response.data.id}-3000`,
|
||||
activatedCount: Math.floor(response.data.activatedCount * 0.4),
|
||||
},
|
||||
]
|
||||
}
|
||||
setFood(foodWithBatches)
|
||||
} else {
|
||||
setError("食物不存在或获取失败")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取食物详情失败:", error)
|
||||
setError(error instanceof Error ? error.message : "获取食物详情失败")
|
||||
toast({
|
||||
title: "获取数据失败",
|
||||
description: error instanceof Error ? error.message : "无法获取食物详情",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchFoodDetail()
|
||||
}, [params.id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
|
||||
<span className="ml-2 text-gray-500">加载中...</span>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !food) {
|
||||
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">
|
||||
{error || `找不到ID为 ${params.id} 的食物`}
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/food">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回食物列表
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
const isPublished = food.status === "已发布"
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="absolute top-0 right-0 w-1/2 h-48 bg-gradient-to-bl from-pink-200 via-orange-200 to-transparent opacity-20 rounded-bl-full" />
|
||||
|
||||
<div className="flex items-center mb-6">
|
||||
<Button variant="ghost" size="sm" className="mr-4" asChild>
|
||||
<Link href="/food">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<DashboardHeader heading={food.name} text={`食物ID: ${food.id}`}>
|
||||
<div className="flex space-x-2 ml-auto">
|
||||
{!isPublished && (
|
||||
<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}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑食物
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<ExportCardsDialog foodId={food.id} />
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="details" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">食物详情</TabsTrigger>
|
||||
<TabsTrigger value="batches">批次管理</TabsTrigger>
|
||||
<TabsTrigger value="analytics">数据分析</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1 border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">食物预览</CardTitle>
|
||||
</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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">食物详情</CardTitle>
|
||||
</CardHeader>
|
||||
<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">{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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">发布日期</p>
|
||||
<p className="font-medium">{food.releaseDate || "尚未发布"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">状态</p>
|
||||
<p className="font-medium">{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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">印刷总数</p>
|
||||
<p className="font-medium">{food.printedCount || 0}</p>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batches" className="space-y-4">
|
||||
<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>
|
||||
<CardDescription>管理食物卡牌的印刷批次和卡牌ID</CardDescription>
|
||||
</div>
|
||||
<AddPrintBatchDialog foodId={food.id} isPublished={isPublished} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<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>
|
||||
{food.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>
|
||||
))}
|
||||
{(!food.batches || food.batches.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={7} 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">
|
||||
<CardHeader>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
3
qy-lty-admin/app/food/loading.tsx
Normal file
3
qy-lty-admin/app/food/loading.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
277
qy-lty-admin/app/food/page.tsx
Normal file
277
qy-lty-admin/app/food/page.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } 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 { 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 { AddFoodDialog } from "@/components/food/add-food-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import Link from "next/link"
|
||||
import type { Food } from "@/components/food/food-detail-dialog"
|
||||
import { getFoods, deleteFood as deleteFoodApi } from "@/lib/api/food"
|
||||
|
||||
export default function FoodPage() {
|
||||
const { toast } = useToast()
|
||||
const [foods, setFoods] = useState<Food[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [selectedFood, setSelectedFood] = useState<Food | null>(null)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
|
||||
const itemsPerPage = 10
|
||||
|
||||
// 状态显示映射
|
||||
const getStatusDisplay = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'published': '已发布',
|
||||
'draft': '草稿',
|
||||
'pending': '待审核',
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 获取食物列表数据
|
||||
const fetchFoods = async (page: number = 1, search: string = "") => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getFoods({
|
||||
page,
|
||||
pageSize: itemsPerPage,
|
||||
search: search || undefined,
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
setFoods(response.data.results || [])
|
||||
setTotal(response.data.count || 0)
|
||||
setTotalPages(Math.ceil((response.data.count || 0) / itemsPerPage))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取食物列表失败:", error)
|
||||
toast({
|
||||
title: "获取数据失败",
|
||||
description: error instanceof Error ? error.message : "无法获取食物列表",
|
||||
variant: "destructive",
|
||||
})
|
||||
setFoods([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
useEffect(() => {
|
||||
fetchFoods(currentPage, searchTerm)
|
||||
}, [currentPage])
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value)
|
||||
setCurrentPage(1)
|
||||
fetchFoods(1, value)
|
||||
}
|
||||
|
||||
// 处理添加食物成功后的刷新
|
||||
const handleAddFood = async () => {
|
||||
// 食物已经在 AddFoodDialog 中创建成功,这里只需要刷新列表
|
||||
fetchFoods(currentPage, searchTerm)
|
||||
}
|
||||
|
||||
// 处理编辑食物成功后的刷新
|
||||
const handleEditFood = async () => {
|
||||
// 食物已经在 AddFoodDialog 中更新成功,这里只需要刷新列表和关闭对话框
|
||||
setSelectedFood(null)
|
||||
setIsEditDialogOpen(false)
|
||||
fetchFoods(currentPage, searchTerm)
|
||||
}
|
||||
|
||||
// 处理删除食物
|
||||
const handleDeleteFood = async (foodId: string) => {
|
||||
try {
|
||||
const response = await deleteFoodApi(foodId)
|
||||
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "食物已成功删除",
|
||||
variant: "destructive",
|
||||
})
|
||||
// 重新获取当前页数据
|
||||
fetchFoods(currentPage, searchTerm)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("删除食物失败:", error)
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: error instanceof Error ? error.message : "删除食物失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEditDialog = (food: Food) => {
|
||||
setSelectedFood(food)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="食物管理" text="管理洛天依的食物卡牌">
|
||||
<AddFoodDialog onSave={handleAddFood} />
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="flex items-center justify-between space-y-2 mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索食物..."
|
||||
className="w-[300px] pl-8 border-none bg-white shadow-md focus-visible:ring-pink-500"
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
|
||||
<span className="ml-2 text-gray-500">加载中...</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>
|
||||
{foods.map((food) => (
|
||||
<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.releaseDate || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
(food.status === "published" || food.status === "已发布")
|
||||
? "bg-green-500 hover:bg-green-600"
|
||||
: "bg-gray-500 hover:bg-gray-600"
|
||||
}
|
||||
>
|
||||
{getStatusDisplay(food.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{food.activatedCount}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
|
||||
<Link href={`/food/${food.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">查看详情</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{food.status !== "published" && food.status !== "已发布" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-pink-50 hover:text-pink-600"
|
||||
onClick={() => openEditDialog(food)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
title="删除食物"
|
||||
description="此操作将永久删除该食物及其所有相关数据。"
|
||||
itemName={food.name}
|
||||
onDelete={() => handleDeleteFood(food.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{foods.length === 0 && !loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
{searchTerm ? "没有找到匹配的食物" : "暂无食物数据"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
显示 {foods.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
|
||||
{Math.min(currentPage * itemsPerPage, total)} 共 {total} 个食物
|
||||
</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 || loading}
|
||||
>
|
||||
上一页
|
||||
</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 || loading}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* 编辑食物对话框 */}
|
||||
{selectedFood && isEditDialogOpen && (
|
||||
<AddFoodDialog
|
||||
mode="edit"
|
||||
initialFood={selectedFood}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
onSave={handleEditFood}
|
||||
/>
|
||||
)}
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
8
qy-lty-admin/app/forgot-password/layout.tsx
Normal file
8
qy-lty-admin/app/forgot-password/layout.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import type React from "react"
|
||||
export default function ForgotPasswordLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <div className="min-h-screen">{children}</div>
|
||||
}
|
||||
12
qy-lty-admin/app/forgot-password/loading.tsx
Normal file
12
qy-lty-admin/app/forgot-password/loading.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
<Loader2 className="h-12 w-12 text-pink-600 animate-spin" />
|
||||
<p className="text-lg text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
259
qy-lty-admin/app/forgot-password/page.tsx
Normal file
259
qy-lty-admin/app/forgot-password/page.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Sparkles, Mail, Phone, ArrowRight, Loader2, CheckCircle2 } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [resetMethod, setResetMethod] = useState<"email" | "phone">("email")
|
||||
const [email, setEmail] = useState("")
|
||||
const [phone, setPhone] = useState("")
|
||||
const [verificationCode, setVerificationCode] = useState("")
|
||||
const [isSendingCode, setIsSendingCode] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
|
||||
const handleResetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// 模拟重置密码请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 显示成功信息
|
||||
setIsSuccess(true)
|
||||
|
||||
// 3秒后跳转到登录页
|
||||
setTimeout(() => {
|
||||
router.push("/login")
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error("重置密码失败", error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendVerificationCode = async () => {
|
||||
if (!phone || phone.length !== 11 || isSendingCode) return
|
||||
|
||||
setIsSendingCode(true)
|
||||
|
||||
try {
|
||||
// 模拟发送验证码请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 开始倒计时
|
||||
setCountdown(60)
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer)
|
||||
setIsSendingCode(false)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
console.error("发送验证码失败", error)
|
||||
setIsSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-gray-50 to-white p-4">
|
||||
<div className="absolute top-0 right-0 w-1/2 h-64 bg-gradient-to-bl from-pink-200 via-purple-200 to-transparent opacity-20 rounded-bl-full" />
|
||||
<div className="absolute bottom-0 left-0 w-1/2 h-64 bg-gradient-to-tr from-blue-200 via-purple-200 to-transparent opacity-20 rounded-tr-full" />
|
||||
|
||||
<Card className="w-full max-w-md border-none shadow-xl bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader className="space-y-2 text-center">
|
||||
<div className="flex justify-center mb-2">
|
||||
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-green-500 to-teal-600 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold text-green-600">重置密码链接已发送</CardTitle>
|
||||
<CardDescription>
|
||||
我们已向您{resetMethod === "email" ? "的邮箱" : "的手机"}发送了重置密码的
|
||||
{resetMethod === "email" ? "链接" : "验证码"}, 请
|
||||
{resetMethod === "email" ? "查看您的邮箱" : "注意查收短信"}。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-gray-500 mb-4">页面将在3秒后自动跳转到登录页面...</p>
|
||||
<Button
|
||||
onClick={() => router.push("/login")}
|
||||
className="bg-gradient-to-r from-green-500 to-teal-600 hover:from-green-600 hover:to-teal-700 transition-all duration-300 shadow-md hover:shadow-lg"
|
||||
>
|
||||
返回登录
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-gray-50 to-white p-4">
|
||||
<div className="absolute top-0 right-0 w-1/2 h-64 bg-gradient-to-bl from-pink-200 via-purple-200 to-transparent opacity-20 rounded-bl-full" />
|
||||
<div className="absolute bottom-0 left-0 w-1/2 h-64 bg-gradient-to-tr from-blue-200 via-purple-200 to-transparent opacity-20 rounded-tr-full" />
|
||||
|
||||
<Card className="w-full max-w-md border-none shadow-xl bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader className="space-y-2 text-center">
|
||||
<div className="flex justify-center mb-2">
|
||||
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center">
|
||||
<Sparkles className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-pink-600 to-purple-600">
|
||||
找回密码
|
||||
</CardTitle>
|
||||
<CardDescription>请选择找回密码的方式</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="email" onValueChange={(value) => setResetMethod(value as "email" | "phone")}>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger
|
||||
value="email"
|
||||
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"
|
||||
>
|
||||
邮箱找回
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="phone"
|
||||
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"
|
||||
>
|
||||
手机找回
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="email">
|
||||
<form onSubmit={handleResetPassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱地址</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="请输入您的注册邮箱"
|
||||
className="pl-10 border-gray-300 focus-visible:ring-pink-500"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">我们将向您的邮箱发送重置密码的链接</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full 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"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
发送中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
发送重置链接
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="phone">
|
||||
<form onSubmit={handleResetPassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号码</Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="请输入您的注册手机号"
|
||||
className="pl-10 border-gray-300 focus-visible:ring-pink-500"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="verificationCode">验证码</Label>
|
||||
<div className="flex space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="verificationCode"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-w-[120px] hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
|
||||
onClick={handleSendVerificationCode}
|
||||
disabled={isSendingCode || !phone || phone.length !== 11}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}秒后重发` : "获取验证码"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full 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"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
验证中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
验证并重置
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4 pt-0">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
记起密码了?{" "}
|
||||
<Link href="/login" className="text-pink-600 hover:text-pink-700 hover:underline">
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
qy-lty-admin/app/globals.css
Normal file
94
qy-lty-admin/app/globals.css
Normal file
@ -0,0 +1,94 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
65
qy-lty-admin/app/home-decor/[id]/loading.tsx
Normal file
65
qy-lty-admin/app/home-decor/[id]/loading.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
export default function HomeDecorDetailLoading() {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex items-center mb-6">
|
||||
<Button variant="ghost" size="sm" className="mr-4" disabled>
|
||||
<Skeleton className="h-4 w-4 mr-2" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</Button>
|
||||
<DashboardHeader heading={<Skeleton className="h-8 w-48" />} text={<Skeleton className="h-5 w-32" />}>
|
||||
<div className="flex space-x-2">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="details" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">家居装饰详情</TabsTrigger>
|
||||
<TabsTrigger value="batches">批次管理</TabsTrigger>
|
||||
<TabsTrigger value="analytics">数据分析</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<Skeleton className="w-full aspect-square max-w-[300px] rounded-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
))}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
327
qy-lty-admin/app/home-decor/[id]/page.tsx
Normal file
327
qy-lty-admin/app/home-decor/[id]/page.tsx
Normal file
@ -0,0 +1,327 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
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 Link from "next/link"
|
||||
import { AddPrintBatchDialog } from "@/components/home-decor/add-print-batch-dialog"
|
||||
import { ExportCardsDialog } from "@/components/home-decor/export-cards-dialog"
|
||||
|
||||
// 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: { id: string } }) {
|
||||
const decor = decorData[params.id as keyof typeof decorData]
|
||||
|
||||
if (!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>
|
||||
<Button asChild>
|
||||
<Link href="/home-decor">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回家居装饰列表
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
const isPublished = decor.status === "已发布"
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<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>
|
||||
<Link href="/home-decor">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<DashboardHeader heading={decor.name} text={`家居装饰ID: ${decor.id}`}>
|
||||
<div className="flex space-x-2 ml-auto">
|
||||
{!isPublished && (
|
||||
<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}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑家居装饰
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<ExportCardsDialog decorId={decor.id} />
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="details" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">家居装饰详情</TabsTrigger>
|
||||
<TabsTrigger value="batches">批次管理</TabsTrigger>
|
||||
<TabsTrigger value="analytics">数据分析</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1 border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">家居装饰预览</CardTitle>
|
||||
</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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">家居装饰详情</CardTitle>
|
||||
</CardHeader>
|
||||
<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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">稀有度</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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">激活数量</p>
|
||||
<p className="font-medium">{decor.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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batches" className="space-y-4">
|
||||
<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>
|
||||
<CardDescription>管理家居装饰卡牌的印刷批次和卡牌ID</CardDescription>
|
||||
</div>
|
||||
<AddPrintBatchDialog decorId={decor.id} isPublished={isPublished} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
</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">
|
||||
<CardHeader>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
3
qy-lty-admin/app/home-decor/loading.tsx
Normal file
3
qy-lty-admin/app/home-decor/loading.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
284
qy-lty-admin/app/home-decor/page.tsx
Normal file
284
qy-lty-admin/app/home-decor/page.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
"use client"
|
||||
|
||||
import { useState } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Edit, Eye } from "lucide-react"
|
||||
import { AddHomeDecorDialog } from "@/components/home-decor/add-home-decor-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
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",
|
||||
},
|
||||
]
|
||||
|
||||
export default function HomeDecorPage() {
|
||||
const { toast } = useToast()
|
||||
const [decors, setDecors] = useState<HomeDecor[]>(initialDecors)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [selectedDecor, setSelectedDecor] = useState<HomeDecor | null>(null)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
|
||||
const itemsPerPage = 5
|
||||
|
||||
// 过滤和分页
|
||||
const filteredDecors = decors.filter(
|
||||
(decor) =>
|
||||
decor.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
decor.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
decor.type.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
|
||||
const totalPages = Math.ceil(filteredDecors.length / itemsPerPage)
|
||||
const paginatedDecors = filteredDecors.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
|
||||
// 处理添加家居装饰
|
||||
const handleAddDecor = (newDecor: HomeDecor) => {
|
||||
setDecors((prevDecors) => [...prevDecors, newDecor])
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: `家居装饰 ${newDecor.name} 已成功添加`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理编辑家居装饰
|
||||
const handleEditDecor = (updatedDecor: HomeDecor) => {
|
||||
setDecors((prevDecors) => prevDecors.map((decor) => (decor.id === updatedDecor.id ? updatedDecor : decor)))
|
||||
setSelectedDecor(null)
|
||||
setIsEditDialogOpen(false)
|
||||
toast({
|
||||
title: "更新成功",
|
||||
description: `家居装饰 ${updatedDecor.name} 已成功更新`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理删除家居装饰
|
||||
const handleDeleteDecor = async (decorId: string) => {
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
setDecors((prevDecors) => prevDecors.filter((decor) => decor.id !== decorId))
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "家居装饰已成功删除",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEditDialog = (decor: HomeDecor) => {
|
||||
setSelectedDecor(decor)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="家居装饰管理" text="管理洛天依的家居装饰卡牌">
|
||||
<AddHomeDecorDialog onSave={handleAddDecor} />
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="flex items-center justify-between space-y-2 mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
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>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{decor.status !== "已发布" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-pink-50 hover:text-pink-600"
|
||||
onClick={() => openEditDialog(decor)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
title="删除家居装饰"
|
||||
description="此操作将永久删除该家居装饰及其所有相关数据。"
|
||||
itemName={decor.name}
|
||||
onDelete={() => handleDeleteDecor(decor.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{paginatedDecors.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} 个家居装饰
|
||||
</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>
|
||||
|
||||
{/* 编辑家居装饰对话框 - 当选中家居装饰时显示 */}
|
||||
{selectedDecor && isEditDialogOpen && (
|
||||
<AddHomeDecorDialog
|
||||
mode="edit"
|
||||
initialDecor={selectedDecor}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
onSave={handleEditDecor}
|
||||
/>
|
||||
)}
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
20
qy-lty-admin/app/layout.tsx
Normal file
20
qy-lty-admin/app/layout.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'v0 App',
|
||||
description: 'Created with v0',
|
||||
generator: 'v0.dev',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
8
qy-lty-admin/app/login/layout.tsx
Normal file
8
qy-lty-admin/app/login/layout.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import type React from "react"
|
||||
export default function LoginLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <div className="min-h-screen">{children}</div>
|
||||
}
|
||||
12
qy-lty-admin/app/login/loading.tsx
Normal file
12
qy-lty-admin/app/login/loading.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
<Loader2 className="h-12 w-12 text-pink-600 animate-spin" />
|
||||
<p className="text-lg text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
275
qy-lty-admin/app/login/page.tsx
Normal file
275
qy-lty-admin/app/login/page.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Sparkles, Mail, Lock, Phone, ArrowRight, Loader2 } from "lucide-react"
|
||||
import { emailLogin, saveAuthToken } from "@/lib/api/auth"
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loginMethod, setLoginMethod] = useState<"email" | "phone">("email")
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [phone, setPhone] = useState("")
|
||||
const [verificationCode, setVerificationCode] = useState("")
|
||||
const [isSendingCode, setIsSendingCode] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
if (loginMethod === "email") {
|
||||
// 使用真实的邮箱登录接口
|
||||
const response = await emailLogin(email, password)
|
||||
console.log(response)
|
||||
|
||||
// 保存登录凭证
|
||||
saveAuthToken(response.data.token)
|
||||
|
||||
// 设置登录状态
|
||||
localStorage.setItem("isLoggedIn", "true")
|
||||
|
||||
// 登录成功后跳转到首页
|
||||
router.push("/")
|
||||
} else {
|
||||
// 手机号登录逻辑保持不变
|
||||
// 模拟登录请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 设置登录状态
|
||||
localStorage.setItem("isLoggedIn", "true")
|
||||
|
||||
// 登录成功后跳转到首页
|
||||
router.push("/")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("登录失败", error)
|
||||
alert(error instanceof Error ? error.message : "登录失败,请重试")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendVerificationCode = async () => {
|
||||
if (!phone || phone.length !== 11 || isSendingCode) return
|
||||
|
||||
setIsSendingCode(true)
|
||||
|
||||
try {
|
||||
// 模拟发送验证码请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 开始倒计时
|
||||
setCountdown(60)
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer)
|
||||
setIsSendingCode(false)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
console.error("发送验证码失败", error)
|
||||
setIsSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-gray-50 to-white p-4">
|
||||
<div className="absolute top-0 right-0 w-1/2 h-64 bg-gradient-to-bl from-pink-200 via-purple-200 to-transparent opacity-20 rounded-bl-full" />
|
||||
<div className="absolute bottom-0 left-0 w-1/2 h-64 bg-gradient-to-tr from-blue-200 via-purple-200 to-transparent opacity-20 rounded-tr-full" />
|
||||
|
||||
<Card className="w-full max-w-md border-none shadow-xl bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader className="space-y-2 text-center">
|
||||
<div className="flex justify-center mb-2">
|
||||
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center">
|
||||
<Sparkles className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-pink-600 to-purple-600">
|
||||
洛天依管理系统
|
||||
</CardTitle>
|
||||
<CardDescription>请登录您的账户以继续</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="email" onValueChange={(value) => setLoginMethod(value as "email" | "phone")}>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger
|
||||
value="email"
|
||||
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"
|
||||
>
|
||||
邮箱登录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="phone"
|
||||
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"
|
||||
>
|
||||
手机登录
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="email">
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱地址</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱地址"
|
||||
className="pl-10 border-gray-300 focus-visible:ring-pink-500"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Link href="/forgot-password" className="text-xs text-pink-600 hover:text-pink-700 hover:underline">
|
||||
忘记密码?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
className="pl-10 border-gray-300 focus-visible:ring-pink-500"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="remember" />
|
||||
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">
|
||||
记住我
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full 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"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
登录中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
登录
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="phone">
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号码</Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="请输入手机号码"
|
||||
className="pl-10 border-gray-300 focus-visible:ring-pink-500"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="verificationCode">验证码</Label>
|
||||
<div className="flex space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="verificationCode"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-w-[120px] hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
|
||||
onClick={handleSendVerificationCode}
|
||||
disabled={isSendingCode || !phone || phone.length !== 11}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}秒后重发` : "获取验证码"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="remember-phone" />
|
||||
<Label htmlFor="remember-phone" className="text-sm font-normal cursor-pointer">
|
||||
记住我
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full 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"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
登录中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
登录
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4 pt-0">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
还没有账户?{" "}
|
||||
<Link href="/register" className="text-pink-600 hover:text-pink-700 hover:underline">
|
||||
注册新账户
|
||||
</Link>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
278
qy-lty-admin/app/outfits/[id]/page.tsx
Normal file
278
qy-lty-admin/app/outfits/[id]/page.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
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 Link from "next/link"
|
||||
import { AddPrintBatchDialog } from "@/components/outfits/add-print-batch-dialog"
|
||||
import { ExportCardsDialog } from "@/components/outfits/export-cards-dialog"
|
||||
|
||||
// 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: { id: string } }) {
|
||||
const outfit = outfitData[params.id as keyof typeof outfitData]
|
||||
|
||||
if (!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>
|
||||
<Button asChild>
|
||||
<Link href="/outfits">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回服装列表
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
const isPublished = outfit.status === "已发布"
|
||||
|
||||
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="flex items-center mb-6">
|
||||
<Button variant="ghost" size="sm" className="mr-4" asChild>
|
||||
<Link href="/outfits">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<DashboardHeader heading={outfit.name} text={`服装ID: ${outfit.id}`}>
|
||||
<div className="flex space-x-2 ml-auto">
|
||||
{!isPublished && (
|
||||
<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}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑服装
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<ExportCardsDialog outfitId={outfit.id} />
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="details" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">服装详情</TabsTrigger>
|
||||
<TabsTrigger value="batches">批次管理</TabsTrigger>
|
||||
<TabsTrigger value="analytics">数据分析</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1 border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">服装预览</CardTitle>
|
||||
</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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">服装详情</CardTitle>
|
||||
</CardHeader>
|
||||
<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">{outfit.type}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">稀有度</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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">激活数量</p>
|
||||
<p className="font-medium">{outfit.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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batches" className="space-y-4">
|
||||
<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>
|
||||
<CardDescription>管理服装卡牌的印刷批次和卡牌ID</CardDescription>
|
||||
</div>
|
||||
<AddPrintBatchDialog outfitId={outfit.id} isPublished={isPublished} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<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-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>
|
||||
))}
|
||||
</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">
|
||||
<CardHeader>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
273
qy-lty-admin/app/outfits/edit/[id]/page.tsx
Normal file
273
qy-lty-admin/app/outfits/edit/[id]/page.tsx
Normal file
@ -0,0 +1,273 @@
|
||||
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 { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { ArrowLeft, Save, AlertTriangle, Upload } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
// Mock data for the outfit details (same as in [id]/page.tsx)
|
||||
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 EditOutfitPage({ params }: { params: { id: string } }) {
|
||||
const outfit = outfitData[params.id as keyof typeof outfitData]
|
||||
|
||||
if (!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>
|
||||
<Button asChild>
|
||||
<Link href="/outfits">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回服装列表
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
const isPublished = outfit.status === "已发布"
|
||||
|
||||
if (isPublished) {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex flex-col items-center justify-center h-[60vh]">
|
||||
<AlertTriangle className="h-16 w-16 text-amber-500 mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">无法编辑</h1>
|
||||
<p className="text-gray-500 mb-2">该服装已发布,基本属性不可修改</p>
|
||||
<p className="text-gray-500 mb-6">您可以在详情页面增加印刷数量</p>
|
||||
<div className="flex space-x-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/outfits">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回服装列表
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={`/outfits/${params.id}`}>查看服装详情</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
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="flex items-center mb-6">
|
||||
<Button variant="ghost" size="sm" className="mr-4" asChild>
|
||||
<Link href={`/outfits/${params.id}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回详情
|
||||
</Link>
|
||||
</Button>
|
||||
<DashboardHeader heading={`编辑服装: ${outfit.name}`} text={`服装ID: ${outfit.id}`} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1 border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">服装图片</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
|
||||
<Upload className="h-6 w-6 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">更换服装图片</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">服装信息</CardTitle>
|
||||
<CardDescription>编辑服装的基本信息</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">服装名称</Label>
|
||||
<Input id="name" defaultValue={outfit.name} className="border-gray-300 focus-visible:ring-pink-500" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">服装类型</Label>
|
||||
<Select
|
||||
defaultValue={
|
||||
outfit.type === "常规"
|
||||
? "regular"
|
||||
: outfit.type === "季节限定"
|
||||
? "seasonal"
|
||||
: outfit.type === "节日限定"
|
||||
? "festival"
|
||||
: "special"
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue />
|
||||
</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">稀有度</Label>
|
||||
<Select
|
||||
defaultValue={
|
||||
outfit.rarity === "普通"
|
||||
? "common"
|
||||
: outfit.rarity === "稀有"
|
||||
? "rare"
|
||||
: outfit.rarity === "史诗"
|
||||
? "epic"
|
||||
: "legendary"
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue />
|
||||
</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">印刷数量</Label>
|
||||
<Input
|
||||
id="print-quantity"
|
||||
type="number"
|
||||
defaultValue={outfit.printedCount}
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
disabled
|
||||
/>
|
||||
<p className="text-xs text-gray-500">印刷数量在批次管理中修改</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">服装描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
defaultValue={outfit.description}
|
||||
className="min-h-[120px] border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-50 rounded-md flex items-start mt-2">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-700">服装发布后将无法修改基本属性。请确保信息准确无误。</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/outfits/${params.id}`}>取消</Link>
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
<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">
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存修改
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
3
qy-lty-admin/app/outfits/loading.tsx
Normal file
3
qy-lty-admin/app/outfits/loading.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
488
qy-lty-admin/app/outfits/page.tsx
Normal file
488
qy-lty-admin/app/outfits/page.tsx
Normal file
@ -0,0 +1,488 @@
|
||||
"use client"
|
||||
|
||||
import { useState } 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 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"
|
||||
|
||||
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 handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
setIsSubmitting(false)
|
||||
setOpen(false)
|
||||
// 重置表单
|
||||
setStep(1)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
setStep(step + 1)
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
setStep(step - 1)
|
||||
}
|
||||
|
||||
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>
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="flex items-center justify-between space-y-2 mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索服装..."
|
||||
className="w-[300px] pl-8 border-none bg-white shadow-md focus-visible:ring-pink-500"
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">0</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>
|
||||
<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">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>服装名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>发布日期</TableHead>
|
||||
<TableHead>激活数量</TableHead>
|
||||
<TableHead>印刷数量</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">OFT001</TableCell>
|
||||
<TableCell className="font-medium text-pink-600">经典原创服装</TableCell>
|
||||
<TableCell>常规</TableCell>
|
||||
<TableCell>2023-10-15</TableCell>
|
||||
<TableCell className="font-medium">1,245</TableCell>
|
||||
<TableCell className="font-medium">2,000</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
|
||||
<Link href="/outfits/OFT001">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">OFT002</TableCell>
|
||||
<TableCell className="font-medium text-blue-600">夏日泳装</TableCell>
|
||||
<TableCell>季节限定</TableCell>
|
||||
<TableCell>2023-06-01</TableCell>
|
||||
<TableCell className="font-medium">876</TableCell>
|
||||
<TableCell className="font-medium">1,500</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
|
||||
<Link href="/outfits/OFT002">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="unpublished" className="space-y-4">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-bold flex items-center">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
未发布服装
|
||||
</span>
|
||||
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
|
||||
</CardTitle>
|
||||
<CardDescription>尚未正式发布的服装,可以修改基本属性</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>服装名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>印刷数量</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">OFT005</TableCell>
|
||||
<TableCell className="font-medium text-teal-600">演唱会礼服</TableCell>
|
||||
<TableCell>特别版</TableCell>
|
||||
<TableCell className="font-medium">1,000</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600" asChild>
|
||||
<Link href="/outfits/OFT005">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
|
||||
<Link href="/outfits/edit/OFT005">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="limited" className="space-y-4">
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-bold flex items-center">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
限定服装
|
||||
</span>
|
||||
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
|
||||
</CardTitle>
|
||||
<CardDescription>限时或特殊活动的限定服装</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Sparkles className="h-12 w-12 text-blue-500 mb-4" />
|
||||
<p className="text-lg font-medium text-gray-700">限定服装列表将在这里显示</p>
|
||||
<p className="text-sm text-gray-500 mt-2">您可以查看所有限时或特殊活动的限定服装</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
192
qy-lty-admin/app/page.tsx
Normal file
192
qy-lty-admin/app/page.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
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 { Overview } from "@/components/overview"
|
||||
import { RecentActivity } from "@/components/recent-activity"
|
||||
import { Brain, Music, Shirt, User, Database, Sparkles } from "lucide-react"
|
||||
import { StatCard } from "@/components/stat-card"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
|
||||
// 检查用户是否已登录
|
||||
// 在实际应用中,这里应该检查用户的会话或令牌
|
||||
useEffect(() => {
|
||||
try {
|
||||
// 模拟检查登录状态
|
||||
const isLoggedIn = localStorage.getItem("isLoggedIn")
|
||||
|
||||
if (!isLoggedIn) {
|
||||
// 如果未登录,重定向到登录页面
|
||||
router.push("/login")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("登录状态检查失败:", error)
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="管理后台" text="洛天依APP管理系统">
|
||||
<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"
|
||||
asChild
|
||||
>
|
||||
<Link href="/settings">
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
系统设置
|
||||
</Link>
|
||||
</Button>
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="总用户数"
|
||||
value="12,345"
|
||||
change="+180 来自上周"
|
||||
icon={<User className="h-5 w-5 text-pink-500" />}
|
||||
trend="up"
|
||||
/>
|
||||
<StatCard
|
||||
title="活跃用户"
|
||||
value="5,732"
|
||||
change="+12% 来自上月"
|
||||
icon={<User className="h-5 w-5 text-purple-500" />}
|
||||
trend="up"
|
||||
/>
|
||||
<StatCard
|
||||
title="卡牌激活"
|
||||
value="8,942"
|
||||
change="+24% 来自上月"
|
||||
icon={<Database className="h-5 w-5 text-blue-500" />}
|
||||
trend="up"
|
||||
/>
|
||||
<StatCard
|
||||
title="对话次数"
|
||||
value="573,128"
|
||||
change="+19% 来自上月"
|
||||
icon={<Brain className="h-5 w-5 text-teal-500" />}
|
||||
trend="up"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4 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>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<Overview />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-3 border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-bold flex items-center">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-teal-600">最近活动</span>
|
||||
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-blue-600 to-teal-600 rounded-full"></div>
|
||||
</CardTitle>
|
||||
<CardDescription>系统最近的操作记录</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecentActivity />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mt-4">
|
||||
<Card className="border-none shadow-lg hover:shadow-xl transition-all duration-300 bg-gradient-to-br from-white to-pink-50 overflow-hidden relative group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-lg font-bold bg-clip-text text-transparent bg-gradient-to-r from-pink-600 to-purple-600">
|
||||
大模型管理
|
||||
</CardTitle>
|
||||
<div className="p-2 rounded-full bg-gradient-to-r from-pink-100 to-purple-100">
|
||||
<Brain className="h-6 w-6 text-pink-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/ai-model">大模型框架系统</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/ai-model/fine-tuning">大模型微调</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/ai-model/voice-clone">语音克隆与合成</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/ai-model/knowledge-base">知识库管理</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-lg hover:shadow-xl transition-all duration-300 bg-gradient-to-br from-white to-purple-50 overflow-hidden relative group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-lg font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-indigo-600">
|
||||
卡牌管理
|
||||
</CardTitle>
|
||||
<div className="p-2 rounded-full bg-gradient-to-r from-purple-100 to-indigo-100">
|
||||
<Shirt className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/outfits">服装卡牌管理</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/props">道具卡牌管理</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/home-decor">家居装饰卡牌管理</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/food">食物卡牌管理</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-lg hover:shadow-xl transition-all duration-300 bg-gradient-to-br from-white to-blue-50 overflow-hidden relative group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-lg font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-teal-600">
|
||||
内容管理
|
||||
</CardTitle>
|
||||
<div className="p-2 rounded-full bg-gradient-to-r from-blue-100 to-teal-100">
|
||||
<Music className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/songs">歌曲管理</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/dances">舞蹈管理</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/affinity">好感度系统</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link href="/achievements">成就系统</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
3
qy-lty-admin/app/permissions/loading.tsx
Normal file
3
qy-lty-admin/app/permissions/loading.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
431
qy-lty-admin/app/permissions/page.tsx
Normal file
431
qy-lty-admin/app/permissions/page.tsx
Normal file
@ -0,0 +1,431 @@
|
||||
"use client"
|
||||
|
||||
import { useState } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Edit } from "lucide-react"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { RoleDialog, type Role } from "@/components/permissions/role-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
// 初始角色数据
|
||||
const initialRoles: Role[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "超级管理员",
|
||||
description: "拥有系统所有权限,可以管理所有功能",
|
||||
userCount: 1,
|
||||
createdAt: "2023-01-01",
|
||||
status: "系统角色",
|
||||
permissions: {
|
||||
"dashboard.view": true,
|
||||
"dashboard.edit": true,
|
||||
"users.view": true,
|
||||
"users.create": true,
|
||||
"users.edit": true,
|
||||
"users.delete": true,
|
||||
"roles.view": true,
|
||||
"roles.create": true,
|
||||
"roles.edit": true,
|
||||
"roles.delete": true,
|
||||
"ai.view": true,
|
||||
"ai.create": true,
|
||||
"ai.edit": true,
|
||||
"ai.delete": true,
|
||||
"outfits.view": true,
|
||||
"outfits.create": true,
|
||||
"outfits.edit": true,
|
||||
"outfits.delete": true,
|
||||
"props.view": true,
|
||||
"props.create": true,
|
||||
"props.edit": true,
|
||||
"props.delete": true,
|
||||
"homeDecor.view": true,
|
||||
"homeDecor.create": true,
|
||||
"homeDecor.edit": true,
|
||||
"homeDecor.delete": true,
|
||||
"food.view": true,
|
||||
"food.create": true,
|
||||
"food.edit": true,
|
||||
"food.delete": true,
|
||||
"songs.view": true,
|
||||
"songs.create": true,
|
||||
"songs.edit": true,
|
||||
"songs.delete": true,
|
||||
"settings.view": true,
|
||||
"settings.edit": true,
|
||||
"affinity.view": true,
|
||||
"affinity.create": true,
|
||||
"affinity.edit": true,
|
||||
"affinity.delete": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "内容管理员",
|
||||
description: "管理系统内容,包括服装、道具、歌曲等",
|
||||
userCount: 3,
|
||||
createdAt: "2023-03-15",
|
||||
status: "自定义角色",
|
||||
permissions: {
|
||||
"dashboard.view": true,
|
||||
"outfits.view": true,
|
||||
"outfits.create": true,
|
||||
"outfits.edit": true,
|
||||
"outfits.delete": true,
|
||||
"props.view": true,
|
||||
"props.create": true,
|
||||
"props.edit": true,
|
||||
"props.delete": true,
|
||||
"homeDecor.view": true,
|
||||
"homeDecor.create": true,
|
||||
"homeDecor.edit": true,
|
||||
"homeDecor.delete": true,
|
||||
"food.view": true,
|
||||
"food.create": true,
|
||||
"food.edit": true,
|
||||
"food.delete": true,
|
||||
"songs.view": true,
|
||||
"songs.create": true,
|
||||
"songs.edit": true,
|
||||
"songs.delete": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "AI模型管理员",
|
||||
description: "管理AI模型、语音合成和知识库",
|
||||
userCount: 2,
|
||||
createdAt: "2023-05-20",
|
||||
status: "自定义角色",
|
||||
permissions: {
|
||||
"dashboard.view": true,
|
||||
"ai.view": true,
|
||||
"ai.create": true,
|
||||
"ai.edit": true,
|
||||
"ai.delete": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "卡牌管理员",
|
||||
description: "管理卡牌系统,包括服装、道具、家居装饰等",
|
||||
userCount: 4,
|
||||
createdAt: "2023-07-10",
|
||||
status: "自定义角色",
|
||||
permissions: {
|
||||
"dashboard.view": true,
|
||||
"outfits.view": true,
|
||||
"outfits.create": true,
|
||||
"outfits.edit": true,
|
||||
"props.view": true,
|
||||
"props.create": true,
|
||||
"props.edit": true,
|
||||
"homeDecor.view": true,
|
||||
"homeDecor.create": true,
|
||||
"homeDecor.edit": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "查看者",
|
||||
description: "只有查看权限,无法修改任何内容",
|
||||
userCount: 2,
|
||||
createdAt: "2023-09-05",
|
||||
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,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default function PermissionsPage() {
|
||||
const [roles, setRoles] = useState<Role[]>(initialRoles)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
// 过滤角色
|
||||
const filteredRoles = roles.filter(
|
||||
(role) =>
|
||||
role.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
role.description.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
// 添加角色
|
||||
const handleAddRole = async (data: any) => {
|
||||
const newRole: Role = {
|
||||
id: `${roles.length + 1}`,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
userCount: 0,
|
||||
createdAt: new Date().toISOString().split("T")[0],
|
||||
status: data.status,
|
||||
permissions: data.permissions,
|
||||
}
|
||||
|
||||
setRoles([...roles, newRole])
|
||||
toast({
|
||||
title: "角色创建成功",
|
||||
description: `角色 "${data.name}" 已成功创建`,
|
||||
variant: "default",
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑角色
|
||||
const handleEditRole = async (data: any) => {
|
||||
if (!editingRole) return
|
||||
|
||||
const updatedRoles = roles.map((role) =>
|
||||
role.id === editingRole.id
|
||||
? {
|
||||
...role,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
status: data.status,
|
||||
permissions: data.permissions,
|
||||
}
|
||||
: role,
|
||||
)
|
||||
|
||||
setRoles(updatedRoles)
|
||||
setEditingRole(null)
|
||||
toast({
|
||||
title: "角色更新成功",
|
||||
description: `角色 "${data.name}" 已成功更新`,
|
||||
variant: "default",
|
||||
})
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
const handleDeleteRole = async (roleId: string) => {
|
||||
const updatedRoles = roles.filter((role) => role.id !== roleId)
|
||||
setRoles(updatedRoles)
|
||||
toast({
|
||||
title: "角色删除成功",
|
||||
description: "角色已成功删除",
|
||||
variant: "default",
|
||||
})
|
||||
}
|
||||
|
||||
// 开始<E5BC80><E5A78B><EFBFBD>辑角色
|
||||
const startEditRole = (role: Role) => {
|
||||
setEditingRole(role)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="权限管理" text="管理系统角色和权限">
|
||||
<RoleDialog mode="add" onSubmit={handleAddRole} />
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="flex items-center justify-between space-y-2 mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索角色..."
|
||||
className="w-[300px] pl-8 border-none bg-white shadow-md focus-visible:ring-pink-500"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-[150px]">角色名称</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>用户数量</TableHead>
|
||||
<TableHead>创建日期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRoles.map((role) => (
|
||||
<TableRow key={role.id} className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">{role.name}</TableCell>
|
||||
<TableCell>{role.description}</TableCell>
|
||||
<TableCell>{role.userCount}</TableCell>
|
||||
<TableCell>{role.createdAt}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={role.status === "系统角色" ? "bg-purple-500" : "bg-blue-500"}>
|
||||
{role.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-pink-50 hover:text-pink-600"
|
||||
onClick={() => startEditRole(role)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{role.status !== "系统角色" && (
|
||||
<DeleteConfirmationDialog
|
||||
title="删除角色"
|
||||
description="此操作将永久删除该角色,且无法恢复。"
|
||||
itemName={role.name}
|
||||
onDelete={() => handleDeleteRole(role.id)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredRoles.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
|
||||
没有找到匹配的角色
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
显示 {filteredRoles.length} 共 {roles.length} 个角色
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50 mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold flex items-center">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-teal-600">权限矩阵</span>
|
||||
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-blue-600 to-teal-600 rounded-full"></div>
|
||||
</CardTitle>
|
||||
<CardDescription>角色权限分配表</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">权限/角色</TableHead>
|
||||
{roles.map((role) => (
|
||||
<TableHead key={role.id}>{role.name}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">仪表盘查看</TableCell>
|
||||
{roles.map((role) => (
|
||||
<TableCell key={role.id}>
|
||||
<Checkbox checked={role.permissions["dashboard.view"]} disabled />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">用户管理</TableCell>
|
||||
{roles.map((role) => (
|
||||
<TableCell key={role.id}>
|
||||
<Checkbox checked={role.permissions["users.view"]} disabled />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">角色权限管理</TableCell>
|
||||
{roles.map((role) => (
|
||||
<TableCell key={role.id}>
|
||||
<Checkbox checked={role.permissions["roles.view"]} disabled />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">AI模型管理</TableCell>
|
||||
{roles.map((role) => (
|
||||
<TableCell key={role.id}>
|
||||
<Checkbox checked={role.permissions["ai.view"]} disabled />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">服装管理</TableCell>
|
||||
{roles.map((role) => (
|
||||
<TableCell key={role.id}>
|
||||
<Checkbox checked={role.permissions["outfits.view"]} disabled />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">道具管理</TableCell>
|
||||
{roles.map((role) => (
|
||||
<TableCell key={role.id}>
|
||||
<Checkbox checked={role.permissions["props.view"]} disabled />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">歌曲管理</TableCell>
|
||||
{roles.map((role) => (
|
||||
<TableCell key={role.id}>
|
||||
<Checkbox checked={role.permissions["songs.view"]} disabled />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell className="font-medium">系统设置</TableCell>
|
||||
{roles.map((role) => (
|
||||
<TableCell key={role.id}>
|
||||
<Checkbox checked={role.permissions["settings.view"]} disabled />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 编辑角色对话框 */}
|
||||
{editingRole && (
|
||||
<RoleDialog
|
||||
mode="edit"
|
||||
open={!!editingRole}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingRole(null)
|
||||
}}
|
||||
onSubmit={handleEditRole}
|
||||
defaultValues={{
|
||||
name: editingRole.name,
|
||||
description: editingRole.description,
|
||||
status: editingRole.status,
|
||||
permissions: editingRole.permissions,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
148
qy-lty-admin/app/props/[id]/loading.tsx
Normal file
148
qy-lty-admin/app/props/[id]/loading.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function PropDetailLoading() {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<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>
|
||||
<Link href="/props">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<DashboardHeader heading={<Skeleton className="h-8 w-48" />} text={<Skeleton className="h-5 w-32" />}>
|
||||
<div className="flex space-x-2">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
</div>
|
||||
|
||||
<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-6">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1 border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<Skeleton className="w-full aspect-square max-w-[300px] rounded-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-64 mt-2" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between mb-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-4 w-16" />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between py-3 border-b">
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
<Skeleton key={j} className="h-4 w-16" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-48 mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics" className="space-y-6">
|
||||
<Card className="border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-56 mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[300px] w-full rounded-lg" />
|
||||
</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>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[200px] w-full rounded-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[200px] w-full rounded-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
382
qy-lty-admin/app/props/[id]/page.tsx
Normal file
382
qy-lty-admin/app/props/[id]/page.tsx
Normal file
@ -0,0 +1,382 @@
|
||||
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 Link from "next/link"
|
||||
import { AddPrintBatchDialog } from "@/components/props/add-print-batch-dialog"
|
||||
import { ExportCardsDialog } from "@/components/props/export-cards-dialog"
|
||||
|
||||
// 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: { id: string } }) {
|
||||
const prop = propData[params.id as keyof typeof propData]
|
||||
|
||||
if (!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>
|
||||
<Button asChild>
|
||||
<Link href="/props">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回道具列表
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
const isPublished = prop.status === "已发布"
|
||||
const activationRate = prop.printedCount > 0 ? Math.round((prop.activatedCount / prop.printedCount) * 100) : 0
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<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>
|
||||
<Link href="/props">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<DashboardHeader heading={prop.name} text={`道具ID: ${prop.id}`}>
|
||||
<div className="flex space-x-2 ml-auto">
|
||||
{!isPublished && (
|
||||
<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}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑道具
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<ExportCardsDialog propId={prop.id} />
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
</div>
|
||||
|
||||
<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-6">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1 border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">道具预览</CardTitle>
|
||||
</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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">道具详情</CardTitle>
|
||||
</CardHeader>
|
||||
<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">{prop.type}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">稀有度</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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">激活数量</p>
|
||||
<p className="font-medium">{prop.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>
|
||||
</div>
|
||||
<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">{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>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
<CardDescription>管理道具卡牌的印刷批次和卡牌ID</CardDescription>
|
||||
</div>
|
||||
<AddPrintBatchDialog propId={prop.id} isPublished={isPublished} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">批次操作</CardTitle>
|
||||
<CardDescription>批量管理卡牌批次</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
3
qy-lty-admin/app/props/loading.tsx
Normal file
3
qy-lty-admin/app/props/loading.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
283
qy-lty-admin/app/props/page.tsx
Normal file
283
qy-lty-admin/app/props/page.tsx
Normal file
@ -0,0 +1,283 @@
|
||||
"use client"
|
||||
|
||||
import { useState } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Edit, Eye } from "lucide-react"
|
||||
import { AddPropDialog } from "@/components/props/add-prop-dialog"
|
||||
import type { Prop } from "@/components/props/prop-detail-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
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",
|
||||
},
|
||||
]
|
||||
|
||||
export default function PropsPage() {
|
||||
const { toast } = useToast()
|
||||
const [props, setProps] = useState<Prop[]>(initialProps)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [selectedProp, setSelectedProp] = useState<Prop | null>(null)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
|
||||
const itemsPerPage = 5
|
||||
|
||||
// 过滤和分页
|
||||
const filteredProps = props.filter(
|
||||
(prop) =>
|
||||
prop.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
prop.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
prop.type.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
|
||||
const totalPages = Math.ceil(filteredProps.length / itemsPerPage)
|
||||
const paginatedProps = filteredProps.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
|
||||
// 处理添加道具
|
||||
const handleAddProp = (newProp: Prop) => {
|
||||
setProps((prevProps) => [...prevProps, newProp])
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: `道具 ${newProp.name} 已成功添加`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理编辑道具
|
||||
const handleEditProp = (updatedProp: Prop) => {
|
||||
setProps((prevProps) => prevProps.map((prop) => (prop.id === updatedProp.id ? updatedProp : prop)))
|
||||
setSelectedProp(null)
|
||||
setIsEditDialogOpen(false)
|
||||
toast({
|
||||
title: "更新成功",
|
||||
description: `道具 ${updatedProp.name} 已成功更新`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理删除道具
|
||||
const handleDeleteProp = async (propId: string) => {
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
setProps((prevProps) => prevProps.filter((prop) => prop.id !== propId))
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "道具已成功删除",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEditDialog = (prop: Prop) => {
|
||||
setSelectedProp(prop)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="道具管理" text="管理洛天依的道具卡牌">
|
||||
<AddPropDialog onSave={handleAddProp} />
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="flex items-center justify-between space-y-2 mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
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>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{prop.status !== "已发布" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-pink-50 hover:text-pink-600"
|
||||
onClick={() => openEditDialog(prop)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
title="删除道具"
|
||||
description="此操作将永久删除该道具及其所有相关数据。"
|
||||
itemName={prop.name}
|
||||
onDelete={() => handleDeleteProp(prop.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{paginatedProps.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} 个道具
|
||||
</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>
|
||||
|
||||
{/* 编辑道具对话框 - 当选中道具时显示 */}
|
||||
{selectedProp && isEditDialogOpen && (
|
||||
<AddPropDialog
|
||||
mode="edit"
|
||||
initialProp={selectedProp}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
onSave={handleEditProp}
|
||||
/>
|
||||
)}
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
8
qy-lty-admin/app/register/layout.tsx
Normal file
8
qy-lty-admin/app/register/layout.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import type React from "react"
|
||||
export default function RegisterLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <div className="min-h-screen">{children}</div>
|
||||
}
|
||||
12
qy-lty-admin/app/register/loading.tsx
Normal file
12
qy-lty-admin/app/register/loading.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
<Loader2 className="h-12 w-12 text-pink-600 animate-spin" />
|
||||
<p className="text-lg text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
240
qy-lty-admin/app/register/page.tsx
Normal file
240
qy-lty-admin/app/register/page.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Sparkles, Mail, Lock, Phone, User, ArrowRight, Loader2 } from "lucide-react"
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [username, setUsername] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [phone, setPhone] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [verificationCode, setVerificationCode] = useState("")
|
||||
const [isSendingCode, setIsSendingCode] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// 模拟注册请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 注册成功后跳转到登录页
|
||||
router.push("/login")
|
||||
} catch (error) {
|
||||
console.error("注册失败", error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendVerificationCode = async () => {
|
||||
if (!phone || phone.length !== 11 || isSendingCode) return
|
||||
|
||||
setIsSendingCode(true)
|
||||
|
||||
try {
|
||||
// 模拟发送验证码请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 开始倒计时
|
||||
setCountdown(60)
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer)
|
||||
setIsSendingCode(false)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
console.error("发送验证码失败", error)
|
||||
setIsSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-gray-50 to-white p-4">
|
||||
<div className="absolute top-0 right-0 w-1/2 h-64 bg-gradient-to-bl from-pink-200 via-purple-200 to-transparent opacity-20 rounded-bl-full" />
|
||||
<div className="absolute bottom-0 left-0 w-1/2 h-64 bg-gradient-to-tr from-blue-200 via-purple-200 to-transparent opacity-20 rounded-tr-full" />
|
||||
|
||||
<Card className="w-full max-w-md border-none shadow-xl bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader className="space-y-2 text-center">
|
||||
<div className="flex justify-center mb-2">
|
||||
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center">
|
||||
<Sparkles className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-pink-600 to-purple-600">
|
||||
创建新账户
|
||||
</CardTitle>
|
||||
<CardDescription>请填写以下信息完成注册</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
className="pl-10 border-gray-300 focus-visible:ring-pink-500"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱地址</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱地址"
|
||||
className="pl-10 border-gray-300 focus-visible:ring-pink-500"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号码</Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="请输入手机号码"
|
||||
className="pl-10 border-gray-300 focus-visible:ring-pink-500"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="verificationCode">验证码</Label>
|
||||
<div className="flex space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="verificationCode"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-w-[120px] hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
|
||||
onClick={handleSendVerificationCode}
|
||||
disabled={isSendingCode || !phone || phone.length !== 11}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}秒后重发` : "获取验证码"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
className="pl-10 border-gray-300 focus-visible:ring-pink-500"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认密码</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
className="pl-10 border-gray-300 focus-visible:ring-pink-500"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms" required />
|
||||
<Label htmlFor="terms" className="text-sm font-normal cursor-pointer">
|
||||
我已阅读并同意
|
||||
<Link href="/terms" className="text-pink-600 hover:text-pink-700 hover:underline ml-1">
|
||||
服务条款
|
||||
</Link>
|
||||
和
|
||||
<Link href="/privacy" className="text-pink-600 hover:text-pink-700 hover:underline ml-1">
|
||||
隐私政策
|
||||
</Link>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full 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"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
注册中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
注册
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-4 pt-0">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
已有账户?{" "}
|
||||
<Link href="/login" className="text-pink-600 hover:text-pink-700 hover:underline">
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
qy-lty-admin/app/settings/loading.tsx
Normal file
3
qy-lty-admin/app/settings/loading.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
476
qy-lty-admin/app/settings/page.tsx
Normal file
476
qy-lty-admin/app/settings/page.tsx
Normal file
@ -0,0 +1,476 @@
|
||||
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 { Label } from "@/components/ui/label"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Save, RefreshCw, Database, Shield, Bell, Sparkles } from "lucide-react"
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="系统设置" text="管理系统配置和参数">
|
||||
<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">
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存设置
|
||||
</Button>
|
||||
</DashboardHeader>
|
||||
|
||||
<Tabs defaultValue="general" className="space-y-4">
|
||||
<TabsList className="bg-white p-1 shadow-md rounded-lg border">
|
||||
<TabsTrigger
|
||||
value="general"
|
||||
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="database"
|
||||
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="security"
|
||||
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="notifications"
|
||||
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="general" 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 className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="system-name">系统名称</Label>
|
||||
<Input
|
||||
id="system-name"
|
||||
defaultValue="洛天依管理系统"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="system-version">系统版本</Label>
|
||||
<Input
|
||||
id="system-version"
|
||||
defaultValue="1.0.0"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="system-description">系统描述</Label>
|
||||
<Textarea
|
||||
id="system-description"
|
||||
defaultValue="洛天依APP管理系统,用于管理洛天依的AI模型、服装、道具等内容。"
|
||||
className="min-h-[100px] border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language">系统语言</Label>
|
||||
<Select defaultValue="zh-CN">
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zh-CN">简体中文</SelectItem>
|
||||
<SelectItem value="en-US">English</SelectItem>
|
||||
<SelectItem value="ja-JP">日本語</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">系统时区</Label>
|
||||
<Select defaultValue="Asia/Shanghai">
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Asia/Shanghai">中国标准时间 (UTC+8)</SelectItem>
|
||||
<SelectItem value="America/New_York">美国东部时间 (UTC-5)</SelectItem>
|
||||
<SelectItem value="Europe/London">格林威治标准时间 (UTC+0)</SelectItem>
|
||||
<SelectItem value="Asia/Tokyo">日本标准时间 (UTC+9)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="maintenance-mode" />
|
||||
<Label htmlFor="maintenance-mode" className="cursor-pointer">
|
||||
启用维护模式
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
恢复默认设置
|
||||
</Button>
|
||||
<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">
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存设置
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-bold flex items-center">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-teal-600">
|
||||
系统外观
|
||||
</span>
|
||||
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-blue-600 to-teal-600 rounded-full"></div>
|
||||
</CardTitle>
|
||||
<CardDescription>配置系统外观和主题</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme">系统主题</Label>
|
||||
<Select defaultValue="light">
|
||||
<SelectTrigger className="border-gray-300 focus:ring-blue-500">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">浅色主题</SelectItem>
|
||||
<SelectItem value="dark">深色主题</SelectItem>
|
||||
<SelectItem value="system">跟随系统</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="primary-color">主题色</Label>
|
||||
<Select defaultValue="pink-purple">
|
||||
<SelectTrigger className="border-gray-300 focus:ring-blue-500">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pink-purple">粉紫渐变</SelectItem>
|
||||
<SelectItem value="blue-teal">蓝青渐变</SelectItem>
|
||||
<SelectItem value="orange-red">橙红渐变</SelectItem>
|
||||
<SelectItem value="green-teal">绿青渐变</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="animations" defaultChecked />
|
||||
<Label htmlFor="animations" className="cursor-pointer">
|
||||
启用界面动画
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button className="bg-gradient-to-r from-blue-500 to-teal-600 hover:from-blue-600 hover:to-teal-700 transition-all duration-300 shadow-md hover:shadow-lg">
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
应用主题
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="database" 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 className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-type">数据库类型</Label>
|
||||
<Select defaultValue="mysql">
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
<SelectItem value="postgresql">PostgreSQL</SelectItem>
|
||||
<SelectItem value="mongodb">MongoDB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-host">数据库主机</Label>
|
||||
<Input
|
||||
id="db-host"
|
||||
defaultValue="localhost"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-port">数据库端口</Label>
|
||||
<Input id="db-port" defaultValue="3306" className="border-gray-300 focus-visible:ring-pink-500" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-name">数据库名称</Label>
|
||||
<Input
|
||||
id="db-name"
|
||||
defaultValue="luo_tianyi_app"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-user">数据库用户名</Label>
|
||||
<Input id="db-user" defaultValue="admin" className="border-gray-300 focus-visible:ring-pink-500" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-password">数据库密码</Label>
|
||||
<Input
|
||||
id="db-password"
|
||||
type="password"
|
||||
defaultValue="********"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="db-ssl" defaultChecked />
|
||||
<Label htmlFor="db-ssl" className="cursor-pointer">
|
||||
启用SSL连接
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200">
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
测试连接
|
||||
</Button>
|
||||
<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">
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存设置
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" 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 className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="session-timeout">会话超时时间(分钟)</Label>
|
||||
<Input
|
||||
id="session-timeout"
|
||||
type="number"
|
||||
defaultValue="30"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-login-attempts">最大登录尝试次数</Label>
|
||||
<Input
|
||||
id="max-login-attempts"
|
||||
type="number"
|
||||
defaultValue="5"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password-policy">密码策略</Label>
|
||||
<Select defaultValue="strong">
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="basic">基本(至少8个字符)</SelectItem>
|
||||
<SelectItem value="medium">中等(至少8个字符,包含数字和字母)</SelectItem>
|
||||
<SelectItem value="strong">强(至少8个字符,包含大小写字母、数字和特殊字符)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password-expiry">密码过期时间(天)</Label>
|
||||
<Input
|
||||
id="password-expiry"
|
||||
type="number"
|
||||
defaultValue="90"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="two-factor-auth" defaultChecked />
|
||||
<Label htmlFor="two-factor-auth" className="cursor-pointer">
|
||||
启用双因素认证
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="ip-restriction" />
|
||||
<Label htmlFor="ip-restriction" className="cursor-pointer">
|
||||
启用IP限制
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
恢复默认设置
|
||||
</Button>
|
||||
<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">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
保存安全设置
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications" 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 className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email-server">邮件服务器</Label>
|
||||
<Input
|
||||
id="email-server"
|
||||
defaultValue="smtp.example.com"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email-port">邮件服务器端口</Label>
|
||||
<Input id="email-port" defaultValue="587" className="border-gray-300 focus-visible:ring-pink-500" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email-encryption">加密方式</Label>
|
||||
<Select defaultValue="tls">
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">无</SelectItem>
|
||||
<SelectItem value="ssl">SSL</SelectItem>
|
||||
<SelectItem value="tls">TLS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email-username">邮件账户</Label>
|
||||
<Input
|
||||
id="email-username"
|
||||
defaultValue="noreply@example.com"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email-password">邮件密码</Label>
|
||||
<Input
|
||||
id="email-password"
|
||||
type="password"
|
||||
defaultValue="********"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>通知类型</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="notify-login" defaultChecked />
|
||||
<Label htmlFor="notify-login" className="cursor-pointer">
|
||||
登录通知
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="notify-content" defaultChecked />
|
||||
<Label htmlFor="notify-content" className="cursor-pointer">
|
||||
内容更新通知
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="notify-system" defaultChecked />
|
||||
<Label htmlFor="notify-system" className="cursor-pointer">
|
||||
系统更新通知
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="notify-security" defaultChecked />
|
||||
<Label htmlFor="notify-security" className="cursor-pointer">
|
||||
安全警报通知
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
恢复默认设置
|
||||
</Button>
|
||||
<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">
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
保存通知设置
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
3
qy-lty-admin/app/songs/[id]/loading.tsx
Normal file
3
qy-lty-admin/app/songs/[id]/loading.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
651
qy-lty-admin/app/songs/[id]/page.tsx
Normal file
651
qy-lty-admin/app/songs/[id]/page.tsx
Normal file
@ -0,0 +1,651 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
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, Play, Pause, Volume2, VolumeX, Music, Download, Factory } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { AddPrintBatchDialog } from "@/components/songs/add-print-batch-dialog"
|
||||
import { ExportCardsDialog } from "@/components/songs/export-cards-dialog"
|
||||
import { ExportBatchDialog } from "@/components/songs/export-batch-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { getSong, getSongBatches, markBatchAsProduced, SongBatch } from "@/lib/api/songs"
|
||||
import { Song } from "@/lib/api/types"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { useParams } from "next/navigation"
|
||||
import { format } from "date-fns"
|
||||
|
||||
export default function SongDetailPage() {
|
||||
const params = useParams()
|
||||
const songId = params.id as string
|
||||
const { toast } = useToast()
|
||||
const [song, setSong] = useState<Song | null>(null)
|
||||
const [batches, setBatches] = useState<SongBatch[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isBatchesLoading, setIsBatchesLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isAudioLoading, setIsAudioLoading] = useState(false)
|
||||
const [volume, setVolume] = useState(80)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
// 加载歌曲数据
|
||||
useEffect(() => {
|
||||
const fetchSong = async () => {
|
||||
if (!songId) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const songData = await getSong(songId)
|
||||
setSong(songData)
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
console.error("获取歌曲失败:", error)
|
||||
setError(error instanceof Error ? error.message : "未知错误")
|
||||
toast({
|
||||
title: "获取歌曲失败",
|
||||
description: error instanceof Error ? error.message : "无法加载歌曲数据,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSong()
|
||||
}, [songId, toast])
|
||||
|
||||
// 加载批次数据
|
||||
useEffect(() => {
|
||||
const fetchBatches = async () => {
|
||||
if (!songId) return
|
||||
|
||||
setIsBatchesLoading(true)
|
||||
try {
|
||||
const batchData = await getSongBatches(songId)
|
||||
setBatches(batchData)
|
||||
} catch (error) {
|
||||
console.error("获取批次数据失败:", error)
|
||||
toast({
|
||||
title: "获取批次数据失败",
|
||||
description: error instanceof Error ? error.message : "无法加载批次数据,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsBatchesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchBatches()
|
||||
}, [songId, toast])
|
||||
|
||||
// 刷新批次数据的函数
|
||||
const refreshBatches = async () => {
|
||||
if (!songId) return
|
||||
|
||||
setIsBatchesLoading(true)
|
||||
try {
|
||||
const batchData = await getSongBatches(songId)
|
||||
setBatches(batchData)
|
||||
} catch (error) {
|
||||
console.error("刷新批次数据失败:", error)
|
||||
} finally {
|
||||
setIsBatchesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化音频播放器 - 现在只创建但不自动播放
|
||||
useEffect(() => {
|
||||
if (song?.audioUrl && !audioRef.current) {
|
||||
audioRef.current = new Audio(song.audioUrl)
|
||||
|
||||
const audio = audioRef.current
|
||||
|
||||
// 音频加载完成时 - 不再自动播放
|
||||
audio.addEventListener('canplay', () => {
|
||||
setIsAudioLoading(false)
|
||||
})
|
||||
|
||||
// 音频播放结束时
|
||||
audio.addEventListener('ended', () => {
|
||||
setIsPlaying(false)
|
||||
})
|
||||
|
||||
// 音频播放错误时
|
||||
audio.addEventListener('error', () => {
|
||||
setIsAudioLoading(false)
|
||||
setIsPlaying(false)
|
||||
toast({
|
||||
title: "播放错误",
|
||||
description: "无法播放该音频文件",
|
||||
variant: "destructive",
|
||||
})
|
||||
})
|
||||
|
||||
// 设置音量
|
||||
audio.volume = volume / 100
|
||||
audio.muted = isMuted
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
const audio = audioRef.current
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
|
||||
audio.removeEventListener('canplay', () => {})
|
||||
audio.removeEventListener('ended', () => {})
|
||||
audio.removeEventListener('error', () => {})
|
||||
audioRef.current = null
|
||||
}
|
||||
}
|
||||
}, [song?.audioUrl, toast, volume, isMuted])
|
||||
|
||||
// 监听音量变化
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume / 100
|
||||
}
|
||||
}, [volume])
|
||||
|
||||
// 监听静音状态
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = isMuted
|
||||
}
|
||||
}, [isMuted])
|
||||
|
||||
// 处理播放/暂停
|
||||
const togglePlay = () => {
|
||||
if (!song?.audioUrl) {
|
||||
toast({
|
||||
title: "无法播放",
|
||||
description: "该歌曲没有可播放的音频文件",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
setIsAudioLoading(true)
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true)
|
||||
setIsAudioLoading(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('播放失败:', error)
|
||||
setIsAudioLoading(false)
|
||||
toast({
|
||||
title: "播放失败",
|
||||
description: "无法播放音频,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换静音状态
|
||||
const toggleMute = () => {
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
|
||||
// 处理音量变化
|
||||
const handleVolumeChange = (values: number[]) => {
|
||||
const newVolume = values[0]
|
||||
setVolume(newVolume)
|
||||
|
||||
// 如果音量从0调高,则取消静音
|
||||
if (newVolume > 0 && isMuted) {
|
||||
setIsMuted(false)
|
||||
}
|
||||
// 如果音量调到0,则设置为静音
|
||||
else if (newVolume === 0 && !isMuted) {
|
||||
setIsMuted(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 标记批次为已生产
|
||||
const handleMarkAsProduced = async (batchId: number) => {
|
||||
try {
|
||||
setIsBatchesLoading(true);
|
||||
await markBatchAsProduced(batchId);
|
||||
toast({
|
||||
title: "操作成功",
|
||||
description: "批次已成功标记为已生产",
|
||||
});
|
||||
await refreshBatches();
|
||||
} catch (error) {
|
||||
console.error("标记批次为已生产失败:", error);
|
||||
toast({
|
||||
title: "操作失败",
|
||||
description: error instanceof Error ? error.message : "无法标记批次为已生产,请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsBatchesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
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为 {songId} 的歌曲</p>
|
||||
<Button asChild>
|
||||
<Link href="/songs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回歌曲列表
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading || !song) {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<div className="flex items-center mb-6">
|
||||
<Button variant="ghost" size="sm" className="mr-4" asChild>
|
||||
<Link href="/songs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="w-full">
|
||||
<Skeleton className="h-10 w-64 mb-2" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Skeleton className="h-[400px]" />
|
||||
<Skeleton className="h-[400px] md:col-span-2" />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
const isPublished = song.status === "已发布";
|
||||
|
||||
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="flex items-center mb-6">
|
||||
<Button variant="ghost" size="sm" className="mr-4" asChild>
|
||||
<Link href="/songs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<DashboardHeader heading={song.name} text={`歌曲ID: ${song.id}`}>
|
||||
<div className="flex space-x-2 ml-auto">
|
||||
{song.audioUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`${
|
||||
isPlaying
|
||||
? "bg-pink-100 text-pink-700 hover:bg-pink-200"
|
||||
: "hover:bg-pink-50 hover:text-pink-700"
|
||||
}`}
|
||||
onClick={togglePlay}
|
||||
disabled={isAudioLoading}
|
||||
>
|
||||
{isAudioLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
加载中...
|
||||
</>
|
||||
) : isPlaying ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
暂停播放
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
播放歌曲
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isPublished && (
|
||||
<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={`/songs/edit/${songId}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑歌曲
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<ExportCardsDialog songId={song.id} />
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
</div>
|
||||
|
||||
{/* 音频控制器 - 仅当歌曲有音频URL且正在播放时显示 */}
|
||||
{song.audioUrl && isPlaying && (
|
||||
<div className="mb-4">
|
||||
<div className="bg-pink-50 p-3 rounded-lg border border-pink-100 flex items-center">
|
||||
<div className="flex-shrink-0 mr-3">
|
||||
<Music className="h-5 w-5 text-pink-500" />
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-grow">
|
||||
<p className="text-sm font-medium text-pink-700 truncate">
|
||||
正在播放: {song.name}
|
||||
</p>
|
||||
<p className="text-xs text-pink-500 truncate">
|
||||
{song.composer}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-pink-100"
|
||||
onClick={toggleMute}
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-4 w-4 text-pink-500" /> : <Volume2 className="h-4 w-4 text-pink-500" />}
|
||||
</Button>
|
||||
<div className="w-32">
|
||||
<Slider
|
||||
value={[volume]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full bg-pink-200 hover:bg-pink-300 text-pink-700"
|
||||
onClick={togglePlay}
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="details" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">歌曲详情</TabsTrigger>
|
||||
<TabsTrigger value="batches">批次管理</TabsTrigger>
|
||||
<TabsTrigger value="analytics">数据分析</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1 border-none shadow-lg bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">歌曲预览</CardTitle>
|
||||
</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 group">
|
||||
<img src={song.image || "/placeholder.svg"} alt={song.name} className="object-cover" />
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{song.status}</Badge>
|
||||
{song.rarity && <Badge className="bg-purple-500">{song.rarity}</Badge>}
|
||||
{song.cardType && <Badge className="bg-blue-500">{song.cardType}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-bold">歌曲详情</CardTitle>
|
||||
</CardHeader>
|
||||
<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">{song.composer}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">作词</p>
|
||||
<p className="font-medium">{song.lyricist}</p>
|
||||
</div>
|
||||
{song.arrangement && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">编曲</p>
|
||||
<p className="font-medium">{song.arrangement}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">时长</p>
|
||||
<p className="font-medium">{song.duration}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">发布日期</p>
|
||||
<p className="font-medium">{song.releaseDate || "尚未发布"}</p>
|
||||
</div>
|
||||
{song.genre && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">风格</p>
|
||||
<p className="font-medium">{song.genre}</p>
|
||||
</div>
|
||||
)}
|
||||
{song.bpm && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">BPM</p>
|
||||
<p className="font-medium">{song.bpm}</p>
|
||||
</div>
|
||||
)}
|
||||
{song.price && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">价格</p>
|
||||
<p className="font-medium">¥{song.price}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">激活数量</p>
|
||||
<p className="font-medium">{song.cardsCount || 0}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500">印刷批次</p>
|
||||
<p className="font-medium">{song.batchesCount || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{song.description && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-gray-500 mb-1">描述</p>
|
||||
<p className="text-sm">{song.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{song.lyrics && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-gray-500 mb-1">歌词</p>
|
||||
<div className="text-sm max-h-40 overflow-y-auto p-3 bg-gray-50 rounded">
|
||||
<pre className="whitespace-pre-wrap font-sans">{song.lyrics}</pre>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batches" className="space-y-4">
|
||||
<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>
|
||||
<CardDescription>管理歌曲卡牌的印刷批次和卡牌ID</CardDescription>
|
||||
</div>
|
||||
<AddPrintBatchDialog songId={song.id} isPublished={isPublished} onBatchCreated={refreshBatches} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 如果没有批次数据,显示空状态 */}
|
||||
{isBatchesLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between mb-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-4 w-16" />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between py-3 border-b">
|
||||
{Array.from({ length: 6 }).map((_, j) => (
|
||||
<Skeleton key={j} className="h-4 w-16" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : batches.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">暂无批次数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<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>
|
||||
{batches.map((batch) => (
|
||||
<tr key={batch.id} className="border-b">
|
||||
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">{batch.id}</td>
|
||||
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">{format(new Date(batch.created_at), 'yyyy-MM-dd')}</td>
|
||||
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">{batch.quantity}</td>
|
||||
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">{batch.start_id}</td>
|
||||
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">{batch.end_id}</td>
|
||||
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">
|
||||
<Badge className={getBatchStatusColor(batch.status)}>
|
||||
{batch.status_display || getBatchStatusText(batch.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<ExportBatchDialog batchId={batch.id} />
|
||||
{batch.status !== 'produced' && batch.status !== 'published' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 hover:bg-green-50 hover:text-green-600"
|
||||
onClick={() => handleMarkAsProduced(batch.id)}
|
||||
>
|
||||
<Factory className="h-4 w-4 mr-1" />
|
||||
生产
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
<CardHeader>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
// 获取批次状态颜色
|
||||
function getBatchStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return 'bg-gray-500';
|
||||
case 'exported':
|
||||
return 'bg-blue-500';
|
||||
case 'in_production':
|
||||
return 'bg-amber-500';
|
||||
case 'produced':
|
||||
return 'bg-purple-500';
|
||||
case 'published':
|
||||
return 'bg-green-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取批次状态文本
|
||||
function getBatchStatusText(status: string): string {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return '草稿';
|
||||
case 'exported':
|
||||
return '已导出';
|
||||
case 'in_production':
|
||||
return '生产中';
|
||||
case 'produced':
|
||||
return '已生产';
|
||||
case 'published':
|
||||
return '已发布';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
3
qy-lty-admin/app/songs/loading.tsx
Normal file
3
qy-lty-admin/app/songs/loading.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
598
qy-lty-admin/app/songs/page.tsx
Normal file
598
qy-lty-admin/app/songs/page.tsx
Normal file
@ -0,0 +1,598 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Edit, Play, Pause, Eye, Volume2, VolumeX, Send } from "lucide-react"
|
||||
import { AddSongDialog } from "@/components/songs/add-song-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import Link from "next/link"
|
||||
import { getSongs, publishSong } from "@/lib/api/songs"
|
||||
import type { Song } from "@/lib/api/types"
|
||||
import type { Song as ComponentSong } from "@/components/songs/song-detail-dialog"
|
||||
import { apiSongToComponentSong, componentSongToApiSong } from "@/lib/api/adapters"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
|
||||
export default function SongsPage() {
|
||||
const { toast } = useToast()
|
||||
|
||||
// 存储API返回的完整歌曲数据,包括原始数据结构,便于后续更新操作
|
||||
const [songs, setSongs] = useState<Song[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [selectedSong, setSelectedSong] = useState<ComponentSong | null>(null)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [playingSongId, setPlayingSongId] = useState<string | null>(null)
|
||||
const [totalSongs, setTotalSongs] = useState(0)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [volume, setVolume] = useState(80)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [isAudioLoading, setIsAudioLoading] = useState(false)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
// 音频播放器引用
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const currentSongUrlRef = useRef<string | null>(null)
|
||||
|
||||
const itemsPerPage = 5
|
||||
|
||||
// 加载歌曲数据
|
||||
useEffect(() => {
|
||||
const fetchSongs = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await getSongs({
|
||||
page: currentPage,
|
||||
pageSize: itemsPerPage,
|
||||
search: searchTerm,
|
||||
})
|
||||
|
||||
// 直接使用API返回的完整歌曲数据
|
||||
// 但确保所有必需属性都有默认值
|
||||
const songItems = response.items.map(song => ({
|
||||
...song,
|
||||
id: song.id || "",
|
||||
name: song.name || "",
|
||||
composer: song.composer || "",
|
||||
lyricist: song.lyricist || "",
|
||||
duration: song.duration || "",
|
||||
status: song.status || "未知"
|
||||
}))
|
||||
|
||||
setSongs(songItems)
|
||||
setTotalSongs(response.total)
|
||||
setTotalPages(response.totalPages)
|
||||
} catch (error) {
|
||||
console.error("获取歌曲失败:", error)
|
||||
toast({
|
||||
title: "获取歌曲失败",
|
||||
description: "无法加载歌曲数据,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSongs()
|
||||
}, [currentPage, searchTerm, toast, refreshTrigger])
|
||||
|
||||
// 创建音频元素
|
||||
useEffect(() => {
|
||||
audioRef.current = new Audio()
|
||||
|
||||
// 设置音频事件监听器
|
||||
const audio = audioRef.current
|
||||
|
||||
// 音频加载完成时
|
||||
audio.addEventListener('canplay', () => {
|
||||
setIsAudioLoading(false)
|
||||
audio.play().catch(err => {
|
||||
console.error("播放失败:", err)
|
||||
toast({
|
||||
title: "播放失败",
|
||||
description: "无法播放音频,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
setPlayingSongId(null)
|
||||
})
|
||||
})
|
||||
|
||||
// 音频播放结束时
|
||||
audio.addEventListener('ended', () => {
|
||||
setPlayingSongId(null)
|
||||
})
|
||||
|
||||
// 音频播放错误时
|
||||
audio.addEventListener('error', () => {
|
||||
setIsAudioLoading(false)
|
||||
setPlayingSongId(null)
|
||||
toast({
|
||||
title: "播放错误",
|
||||
description: "无法播放该音频文件",
|
||||
variant: "destructive",
|
||||
})
|
||||
})
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
audio.removeEventListener('canplay', () => {})
|
||||
audio.removeEventListener('ended', () => {})
|
||||
audio.removeEventListener('error', () => {})
|
||||
}
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
// 监听音量变化
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume / 100
|
||||
}
|
||||
}, [volume])
|
||||
|
||||
// 监听静音状态变化
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = isMuted
|
||||
}
|
||||
}, [isMuted])
|
||||
|
||||
// 处理添加歌曲
|
||||
const handleAddSong = (newSong: ComponentSong) => {
|
||||
// 转换为API Song类型
|
||||
const apiSong = componentSongToApiSong(newSong)
|
||||
|
||||
setSongs((prevSongs) => [...prevSongs, apiSong])
|
||||
setRefreshTrigger(prev => prev + 1) // 触发刷新
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: `歌曲 ${newSong.name} 已成功添加`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理编辑歌曲
|
||||
const handleEditSong = (updatedSong: ComponentSong) => {
|
||||
console.log('保存编辑后的歌曲', updatedSong);
|
||||
|
||||
// 找到原始歌曲
|
||||
const originalSong = songs.find(s => s.id === updatedSong.id);
|
||||
console.log('原始歌曲数据', originalSong);
|
||||
|
||||
if (!originalSong) {
|
||||
console.error('找不到原始歌曲数据');
|
||||
return;
|
||||
}
|
||||
|
||||
// 转换为API Song类型,并保留原始数据
|
||||
const apiSong = componentSongToApiSong(updatedSong, originalSong);
|
||||
console.log('转换后的API歌曲数据', apiSong);
|
||||
|
||||
// 更新本地歌曲数据
|
||||
setSongs((prevSongs) => prevSongs.map((song) => {
|
||||
if (song.id === apiSong.id) {
|
||||
return apiSong;
|
||||
}
|
||||
return song;
|
||||
}));
|
||||
|
||||
setSelectedSong(null);
|
||||
setIsEditDialogOpen(false);
|
||||
setRefreshTrigger(prev => prev + 1); // 触发刷新
|
||||
toast({
|
||||
title: "更新成功",
|
||||
description: `歌曲 ${updatedSong.name} 已成功更新`,
|
||||
});
|
||||
}
|
||||
|
||||
// 处理删除歌曲
|
||||
const handleDeleteSong = async (songId: string) => {
|
||||
// 如果正在播放该歌曲,先停止播放
|
||||
if (playingSongId === songId && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setPlayingSongId(null)
|
||||
}
|
||||
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
setSongs((prevSongs) => prevSongs.filter((song) => song.id !== songId))
|
||||
setRefreshTrigger(prev => prev + 1) // 触发刷新
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "歌曲已成功删除",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEditDialog = (song: Song) => {
|
||||
console.log('编辑歌曲', song);
|
||||
|
||||
// 确保从rawData中获取最新的数据
|
||||
if (song.rawData) {
|
||||
console.log('使用完整原始数据', song.rawData);
|
||||
|
||||
// 转换为组件所需的Song类型,优先使用rawData中的完整数据
|
||||
const attributes = song.rawData.attributes || {};
|
||||
const componentSong: ComponentSong = {
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
composer: attributes.composer || song.composer || "",
|
||||
lyricist: attributes.lyricist || song.lyricist || "",
|
||||
duration: attributes.duration || song.duration || "",
|
||||
releaseDate: song.releaseDate,
|
||||
status: song.rawData.status_display || song.status || "",
|
||||
image: song.rawData.image_url || song.image,
|
||||
audioUrl: attributes.audio_file || song.audioUrl || "",
|
||||
description: song.rawData.description || song.description || "",
|
||||
rarity: song.rawData.rarity_display || song.rarity || "",
|
||||
cardType: song.rawData.card_type_display || song.cardType || "",
|
||||
genre: attributes.genre || song.genre || "",
|
||||
lyrics: attributes.lyrics || song.lyrics || "",
|
||||
bpm: attributes.bpm || song.bpm || 0
|
||||
};
|
||||
|
||||
setSelectedSong(componentSong);
|
||||
} else {
|
||||
// 使用适配器功能
|
||||
const componentSong = apiSongToComponentSong(song);
|
||||
setSelectedSong(componentSong);
|
||||
}
|
||||
|
||||
setIsEditDialogOpen(true);
|
||||
}
|
||||
|
||||
// 处理播放/暂停
|
||||
const togglePlay = (songId: string) => {
|
||||
const song = songs.find(s => s.id === songId)
|
||||
|
||||
// 如果找不到歌曲或没有音频URL,则显示提示f
|
||||
if (!song || !song.audioUrl) {
|
||||
toast({
|
||||
title: "无法播放",
|
||||
description: "该歌曲没有可播放的音频文件",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经在播放这首歌,则暂停
|
||||
if (playingSongId === songId) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
setPlayingSongId(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在播放其他歌曲,先停止
|
||||
if (playingSongId && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
|
||||
// 播放新歌曲
|
||||
setIsAudioLoading(true)
|
||||
setPlayingSongId(songId)
|
||||
|
||||
// 如果是新的URL,则设置src
|
||||
if (currentSongUrlRef.current !== song.audioUrl && audioRef.current) {
|
||||
currentSongUrlRef.current = song.audioUrl
|
||||
audioRef.current.src = song.audioUrl
|
||||
audioRef.current.load()
|
||||
} else if (audioRef.current) {
|
||||
// 如果是相同的URL,直接播放
|
||||
audioRef.current.play().catch(err => {
|
||||
console.error("播放失败:", err)
|
||||
setPlayingSongId(null)
|
||||
setIsAudioLoading(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理音量变化
|
||||
const handleVolumeChange = (values: number[]) => {
|
||||
const newVolume = values[0]
|
||||
setVolume(newVolume)
|
||||
|
||||
// 如果音量从0调高,则取消静音
|
||||
if (newVolume > 0 && isMuted) {
|
||||
setIsMuted(false)
|
||||
}
|
||||
// 如果音量调到0,则设置为静音
|
||||
else if (newVolume === 0 && !isMuted) {
|
||||
setIsMuted(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换静音状态
|
||||
const toggleMute = () => {
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
|
||||
// 处理发布歌曲
|
||||
const handlePublishSong = async (songId: string) => {
|
||||
try {
|
||||
// 找到要发布的歌曲
|
||||
const songToPublish = songs.find(s => s.id === songId);
|
||||
if (!songToPublish) {
|
||||
throw new Error('找不到要发布的歌曲');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
console.log('正在发布歌曲', songId, songToPublish.name);
|
||||
|
||||
// 调用发布API
|
||||
const publishedSong = await publishSong(songId);
|
||||
console.log('发布成功', publishedSong);
|
||||
|
||||
// 更新本地歌曲数据
|
||||
setSongs((prevSongs) => prevSongs.map((song) => {
|
||||
if (song.id === songId) {
|
||||
return {
|
||||
...song,
|
||||
status: "已发布",
|
||||
releaseDate: new Date().toISOString().split('T')[0],
|
||||
rawData: publishedSong.rawData
|
||||
};
|
||||
}
|
||||
return song;
|
||||
}));
|
||||
|
||||
// 触发刷新
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
|
||||
toast({
|
||||
title: "发布成功",
|
||||
description: `歌曲 "${songToPublish.name}" 已成功发布`,
|
||||
variant: "default",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发布歌曲失败:', error);
|
||||
toast({
|
||||
title: "发布失败",
|
||||
description: error instanceof Error ? error.message : "无法发布歌曲,请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="歌曲管理" text="管理洛天依的歌曲库">
|
||||
<AddSongDialog onSave={handleAddSong} />
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="flex items-center justify-between space-y-2 mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
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>
|
||||
|
||||
{/* 音频控制面板 - 当有歌曲正在播放时显示 */}
|
||||
{playingSongId && (
|
||||
<div className="flex items-center space-x-4 bg-pink-50 py-2 px-4 rounded-full shadow-sm">
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full bg-pink-500 text-white hover:bg-pink-600"
|
||||
onClick={() => togglePlay(playingSongId)}
|
||||
>
|
||||
{isAudioLoading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
) : (
|
||||
<Pause className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium text-pink-700">
|
||||
{songs.find(s => s.id === playingSongId)?.name}
|
||||
</div>
|
||||
<div className="text-xs text-pink-500">
|
||||
{songs.find(s => s.id === playingSongId)?.composer}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full hover:bg-pink-100"
|
||||
onClick={toggleMute}
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-3.5 w-3.5 text-pink-500" /> : <Volume2 className="h-3.5 w-3.5 text-pink-500" />}
|
||||
</Button>
|
||||
<div className="w-24">
|
||||
<Slider
|
||||
value={[volume]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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-[50px]"></TableHead>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>歌曲名称</TableHead>
|
||||
<TableHead>作曲</TableHead>
|
||||
<TableHead>作词</TableHead>
|
||||
<TableHead>时长</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : songs.length > 0 ? (
|
||||
songs.map((song) => (
|
||||
<TableRow key={song.id} className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 rounded-full ${
|
||||
playingSongId === song.id
|
||||
? "bg-pink-500 text-white hover:bg-pink-600"
|
||||
: "bg-pink-100 hover:bg-pink-200 text-pink-600"
|
||||
}`}
|
||||
onClick={() => togglePlay(song.id)}
|
||||
disabled={isAudioLoading && playingSongId === song.id}
|
||||
>
|
||||
{isAudioLoading && playingSongId === song.id ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : playingSongId === song.id ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{song.id}</TableCell>
|
||||
<TableCell className="font-medium text-pink-600">{song.name}</TableCell>
|
||||
<TableCell>{song.composer}</TableCell>
|
||||
<TableCell>{song.lyricist}</TableCell>
|
||||
<TableCell>{song.duration}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
song.status === "已发布" ? "bg-green-500 hover:bg-green-600" : "bg-gray-500 hover:bg-gray-600"
|
||||
}
|
||||
>
|
||||
{song.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
|
||||
<Link href={`/songs/${song.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">查看详情</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{song.status !== "已发布" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-pink-50 hover:text-pink-600"
|
||||
onClick={() => openEditDialog(song)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<PublishConfirmationDialog
|
||||
title="发布歌曲"
|
||||
description="发布后该歌曲将可被用户使用"
|
||||
itemName={song.name}
|
||||
onPublish={() => handlePublishSong(song.id)}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
title="删除歌曲"
|
||||
description="此操作将永久删除该歌曲及其所有相关数据。"
|
||||
itemName={song.name}
|
||||
onDelete={() => handleDeleteSong(song.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<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">
|
||||
显示 {songs.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
|
||||
{Math.min(currentPage * itemsPerPage, totalSongs)} 共 {totalSongs} 首歌曲
|
||||
</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>
|
||||
|
||||
{/* 编辑歌曲对话框 - 当选中歌曲时显示 */}
|
||||
{selectedSong && isEditDialogOpen && (
|
||||
<AddSongDialog
|
||||
mode="edit"
|
||||
initialSong={selectedSong}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
onSave={handleEditSong}
|
||||
/>
|
||||
)}
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
3
qy-lty-admin/app/users/loading.tsx
Normal file
3
qy-lty-admin/app/users/loading.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
380
qy-lty-admin/app/users/page.tsx
Normal file
380
qy-lty-admin/app/users/page.tsx
Normal file
@ -0,0 +1,380 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Edit, Loader2 } from "lucide-react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { UserDetailDialog } from "@/components/users/user-detail-dialog"
|
||||
import { UserFormDialog } from "@/components/users/user-form-dialog"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { usersApi, rolesApi, handleApiError } from "@/lib/api"
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState([])
|
||||
const [roles, setRoles] = useState([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [totalUsers, setTotalUsers] = useState(0)
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { toast } = useToast()
|
||||
|
||||
const itemsPerPage = 5
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await usersApi.getUsers({
|
||||
page: currentPage,
|
||||
pageSize: itemsPerPage,
|
||||
search: searchQuery,
|
||||
})
|
||||
|
||||
setUsers(response.items)
|
||||
setTotalPages(response.totalPages)
|
||||
setTotalUsers(response.total)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "获取用户列表失败",
|
||||
description: handleApiError(error),
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const response = await rolesApi.getRoles()
|
||||
setRoles(response.items)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "获取角色列表失败",
|
||||
description: handleApiError(error),
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
fetchRoles()
|
||||
}, [])
|
||||
|
||||
// 当页码、搜索条件变化时重新获取数据
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [currentPage, searchQuery])
|
||||
|
||||
// 添加用户
|
||||
const handleAddUser = async (data) => {
|
||||
try {
|
||||
await usersApi.createUser({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
role: data.role,
|
||||
status: data.status,
|
||||
phone: data.phone,
|
||||
address: data.address,
|
||||
})
|
||||
|
||||
toast({
|
||||
title: "用户创建成功",
|
||||
description: `用户 "${data.name}" 已成功创建`,
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
fetchUsers() // 刷新用户列表
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "创建用户失败",
|
||||
description: handleApiError(error),
|
||||
variant: "destructive",
|
||||
})
|
||||
throw error // 重新抛出错误,让表单组件处理
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
const handleEditUser = async (data) => {
|
||||
if (!editingUser) return
|
||||
|
||||
try {
|
||||
await usersApi.updateUser(editingUser.id, {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
role: data.role,
|
||||
status: data.status,
|
||||
phone: data.phone,
|
||||
address: data.address,
|
||||
})
|
||||
|
||||
toast({
|
||||
title: "用户更新成功",
|
||||
description: `用户 "${data.name}" 已成功更新`,
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
setEditingUser(null)
|
||||
fetchUsers() // 刷新用户列表
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "更新用户失败",
|
||||
description: handleApiError(error),
|
||||
variant: "destructive",
|
||||
})
|
||||
throw error // 重新抛出错误,让表单组件处理
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
const handleDeleteUser = async (userId) => {
|
||||
try {
|
||||
await usersApi.deleteUser(userId)
|
||||
|
||||
toast({
|
||||
title: "用户删除成功",
|
||||
description: "用户已成功删除",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
fetchUsers() // 刷新用户列表
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "删除用户失败",
|
||||
description: handleApiError(error),
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 开始编辑用户
|
||||
const startEditUser = (user) => {
|
||||
setEditingUser(user)
|
||||
}
|
||||
|
||||
// 获取头像颜色
|
||||
const getAvatarColor = (name) => {
|
||||
const colors = [
|
||||
"from-pink-500 to-purple-500",
|
||||
"from-blue-500 to-teal-500",
|
||||
"from-red-500 to-orange-500",
|
||||
"from-green-500 to-emerald-500",
|
||||
"from-purple-500 to-indigo-500",
|
||||
]
|
||||
|
||||
// Simple hash function to pick a color based on name
|
||||
const hash = name.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
return colors[hash % colors.length]
|
||||
}
|
||||
|
||||
// 获取用户名首字母
|
||||
const getInitials = (name) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.substring(0, 2)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="用户管理" text="管理系统用户和权限">
|
||||
<UserFormDialog
|
||||
mode="add"
|
||||
onSubmit={handleAddUser}
|
||||
roles={roles.map((role) => ({ id: role.id, name: role.name }))}
|
||||
/>
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="flex items-center justify-between space-y-2 mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索用户..."
|
||||
className="w-[300px] pl-8 border-none bg-white shadow-md focus-visible:ring-pink-500"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setCurrentPage(1) // 重置到第一页
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-[50px]"></TableHead>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>注册日期</TableHead>
|
||||
<TableHead>最后登录</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
<div className="flex justify-center items-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-purple-500 mr-2" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
|
||||
没有找到匹配的用户
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.id} className="hover:bg-gray-50 transition-colors">
|
||||
<TableCell>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className={`bg-gradient-to-br ${getAvatarColor(user.name)} text-white`}>
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
user.role === "超级管理员"
|
||||
? "bg-purple-500"
|
||||
: user.role === "内容管理员"
|
||||
? "bg-blue-500"
|
||||
: user.role === "AI模型管理员"
|
||||
? "bg-teal-500"
|
||||
: user.role === "卡牌管理员"
|
||||
? "bg-pink-500"
|
||||
: "bg-orange-500"
|
||||
}
|
||||
>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
user.status === "活跃"
|
||||
? "bg-green-500"
|
||||
: user.status === "未激活"
|
||||
? "bg-gray-500"
|
||||
: "bg-red-500"
|
||||
}
|
||||
>
|
||||
{user.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{user.registeredAt}</TableCell>
|
||||
<TableCell>{user.lastLogin || "-"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<UserDetailDialog user={user} onEdit={() => startEditUser(user)} />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-pink-50 hover:text-pink-600"
|
||||
onClick={() => startEditUser(user)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{user.role !== "超级管理员" && (
|
||||
<DeleteConfirmationDialog
|
||||
title="删除用户"
|
||||
description="此操作将永久删除该用户,且无法恢复。"
|
||||
itemName={user.name}
|
||||
onDelete={() => handleDeleteUser(user.id)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
显示 {users.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
|
||||
{Math.min(currentPage * itemsPerPage, totalUsers)} 共 {totalUsers} 个用户
|
||||
</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 || isLoading}
|
||||
>
|
||||
上一页
|
||||
</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 || isLoading}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* 编辑用户对话框 */}
|
||||
{editingUser && (
|
||||
<UserFormDialog
|
||||
mode="edit"
|
||||
open={!!editingUser}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingUser(null)
|
||||
}}
|
||||
onSubmit={handleEditUser}
|
||||
defaultValues={{
|
||||
name: editingUser.name,
|
||||
email: editingUser.email,
|
||||
role: editingUser.role,
|
||||
status: editingUser.status,
|
||||
phone: editingUser.phone,
|
||||
address: editingUser.address,
|
||||
}}
|
||||
roles={roles.map((role) => ({ id: role.id, name: role.name }))}
|
||||
/>
|
||||
)}
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
21
qy-lty-admin/components.json
Normal file
21
qy-lty-admin/components.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Eye, Edit, Award, Zap, Gift, Heart, Coins, Shirt, BadgeCheck } from "lucide-react"
|
||||
import type { Achievement } from "@/lib/api/types"
|
||||
|
||||
type AchievementDetailDialogProps = {
|
||||
achievement: Achievement
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
export function AchievementDetailDialog({ achievement, onEdit }: AchievementDetailDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case "互动":
|
||||
return "bg-blue-500"
|
||||
case "好感度":
|
||||
return "bg-pink-500"
|
||||
case "收集":
|
||||
return "bg-purple-500"
|
||||
case "探索":
|
||||
return "bg-green-500"
|
||||
case "特殊":
|
||||
return "bg-amber-500"
|
||||
case "隐藏":
|
||||
return "bg-gray-500"
|
||||
default:
|
||||
return "bg-gray-500"
|
||||
}
|
||||
}
|
||||
|
||||
const getRewardIcon = (rewardType: string) => {
|
||||
switch (rewardType) {
|
||||
case "经验值":
|
||||
return <Zap className="h-5 w-5 text-blue-500" />
|
||||
case "好感度":
|
||||
return <Heart className="h-5 w-5 text-pink-500" />
|
||||
case "虚拟币":
|
||||
return <Coins className="h-5 w-5 text-amber-500" />
|
||||
case "道具":
|
||||
return <Gift className="h-5 w-5 text-purple-500" />
|
||||
case "服装":
|
||||
return <Shirt className="h-5 w-5 text-teal-500" />
|
||||
case "称号":
|
||||
return <BadgeCheck className="h-5 w-5 text-indigo-500" />
|
||||
default:
|
||||
return <Gift className="h-5 w-5 text-gray-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getIconComponent = (iconName: string) => {
|
||||
// 这里可以根据iconName返回对应的Lucide图标
|
||||
// 简化处理,默认返回Award图标
|
||||
return <Award className="h-6 w-6 text-amber-500" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="hover:bg-amber-50 hover:text-amber-600">
|
||||
<Eye className="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-amber-600 to-orange-600">
|
||||
成就详情
|
||||
</DialogTitle>
|
||||
<DialogDescription>查看成就的详细信息和解锁条件</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="h-16 w-16 rounded-full bg-amber-100 flex items-center justify-center">
|
||||
{achievement.icon ? getIconComponent(achievement.icon) : <Award className="h-8 w-8 text-amber-500" />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">{achievement.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className={getCategoryColor(achievement.category)}>{achievement.category}</Badge>
|
||||
{achievement.isHidden && <Badge className="bg-gray-500">隐藏成就</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-amber-50 rounded-lg">
|
||||
<h4 className="font-medium text-amber-700 mb-2">成就描述</h4>
|
||||
<p>{achievement.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-500">解锁条件</div>
|
||||
<p className="font-medium">{achievement.requirement}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-500">解锁率</div>
|
||||
<p className="font-medium">{achievement.unlockRate ? `${achievement.unlockRate}%` : "未知"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg">
|
||||
<h4 className="font-medium text-amber-700 mb-3">奖励</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
{getRewardIcon(achievement.rewardType)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{achievement.rewardType} x {achievement.rewardAmount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-gray-500">
|
||||
<div>
|
||||
<span>创建时间: </span>
|
||||
<span className="font-medium">{achievement.createdAt}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>更新时间: </span>
|
||||
<span className="font-medium">{achievement.updatedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
{onEdit && (
|
||||
<Button
|
||||
className="bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑成就
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
223
qy-lty-admin/components/achievements/add-achievement-dialog.tsx
Normal file
223
qy-lty-admin/components/achievements/add-achievement-dialog.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Trophy, Loader2 } from "lucide-react"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import type { Achievement } from "@/lib/api/types"
|
||||
|
||||
type AddAchievementDialogProps = {
|
||||
onAchievementAdded: () => void
|
||||
}
|
||||
|
||||
export function AddAchievementDialog({ onAchievementAdded }: AddAchievementDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [formData, setFormData] = useState<Partial<Achievement>>({
|
||||
name: "",
|
||||
description: "",
|
||||
category: "互动",
|
||||
requirement: "",
|
||||
rewardType: "经验值",
|
||||
rewardAmount: 100,
|
||||
isHidden: false,
|
||||
})
|
||||
|
||||
const handleChange = (field: keyof Achievement, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name || !formData.description || !formData.requirement) {
|
||||
toast({
|
||||
title: "表单不完整",
|
||||
description: "请填写所有必填字段",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
toast({
|
||||
title: "成就添加成功",
|
||||
description: `成就 "${formData.name}" 已成功添加到系统`,
|
||||
})
|
||||
|
||||
setOpen(false)
|
||||
setFormData({
|
||||
name: "",
|
||||
description: "",
|
||||
category: "互动",
|
||||
requirement: "",
|
||||
rewardType: "经验值",
|
||||
rewardAmount: 100,
|
||||
isHidden: false,
|
||||
})
|
||||
onAchievementAdded()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "添加失败",
|
||||
description: "添加成就时发生错误,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700">
|
||||
<Trophy 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-amber-600 to-orange-600">
|
||||
添加新成就
|
||||
</DialogTitle>
|
||||
<DialogDescription>创建新的系统成就和解锁条件</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">成就名称 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange("name", e.target.value)}
|
||||
placeholder="输入成就名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">成就类别 *</Label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择类别" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>成就类别</SelectLabel>
|
||||
<SelectItem value="互动">互动</SelectItem>
|
||||
<SelectItem value="好感度">好感度</SelectItem>
|
||||
<SelectItem value="收集">收集</SelectItem>
|
||||
<SelectItem value="探索">探索</SelectItem>
|
||||
<SelectItem value="特殊">特殊</SelectItem>
|
||||
<SelectItem value="隐藏">隐藏</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">成就描述 *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange("description", e.target.value)}
|
||||
placeholder="输入成就描述"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requirement">解锁条件 *</Label>
|
||||
<Textarea
|
||||
id="requirement"
|
||||
value={formData.requirement}
|
||||
onChange={(e) => handleChange("requirement", e.target.value)}
|
||||
placeholder="输入解锁条件"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rewardType">奖励类型</Label>
|
||||
<Select value={formData.rewardType} onValueChange={(value) => handleChange("rewardType", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择奖励类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>奖励类型</SelectLabel>
|
||||
<SelectItem value="经验值">经验值</SelectItem>
|
||||
<SelectItem value="好感度">好感度</SelectItem>
|
||||
<SelectItem value="虚拟币">虚拟币</SelectItem>
|
||||
<SelectItem value="道具">道具</SelectItem>
|
||||
<SelectItem value="服装">服装</SelectItem>
|
||||
<SelectItem value="称号">称号</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rewardAmount">奖励数量</Label>
|
||||
<Input
|
||||
id="rewardAmount"
|
||||
type="number"
|
||||
value={formData.rewardAmount}
|
||||
onChange={(e) => handleChange("rewardAmount", Number.parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="isHidden"
|
||||
checked={formData.isHidden}
|
||||
onCheckedChange={(checked) => handleChange("isHidden", checked)}
|
||||
/>
|
||||
<Label htmlFor="isHidden">隐藏成就(用户无法在成就列表中看到)</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trophy className="mr-2 h-4 w-4" />}
|
||||
{loading ? "添加中..." : "添加成就"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
345
qy-lty-admin/components/add-outfit-dialog.tsx
Normal file
345
qy-lty-admin/components/add-outfit-dialog.tsx
Normal file
@ -0,0 +1,345 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
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 { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function AddOutfitDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(1)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [outfitType, setOutfitType] = useState("")
|
||||
const [rarity, setRarity] = useState("")
|
||||
const [printQuantity, setPrintQuantity] = useState(1000)
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [previewId, setPreviewId] = useState(
|
||||
"OFT" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
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"),
|
||||
)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
setStep(step + 1)
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
setStep(step - 1)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
setStep(1)
|
||||
}
|
||||
|
||||
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]">
|
||||
<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>
|
||||
|
||||
<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="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 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>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">点击或拖拽上传服装图片</p>
|
||||
<p className="text-xs text-gray-400 mt-1">支持 PNG, JPG, JPEG 格式,最大 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label className="text-right">服装预览图 (可选)</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">点击或拖拽上传服装预览图</p>
|
||||
<p className="text-xs text-gray-400 mt-1">建议上传多角度预览图,最多5张</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start mt-2">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium">图片要求</p>
|
||||
<p>主图将用于卡牌印刷和APP展示,请确保图片清晰、比例合适。建议使用透明背景的PNG格式。</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step-3" className="space-y-4 py-4">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
320
qy-lty-admin/components/affinity/affinity-level-dialog.tsx
Normal file
320
qy-lty-admin/components/affinity/affinity-level-dialog.tsx
Normal file
@ -0,0 +1,320 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
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, Loader2, Award } from "lucide-react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
// 定义好感度等级类型
|
||||
export type AffinityLevel = {
|
||||
id: string
|
||||
level: number
|
||||
name: string
|
||||
minAffinity: number
|
||||
maxAffinity: number
|
||||
unlockContent: string
|
||||
rewardType: string
|
||||
rewardItems?: string
|
||||
rewardCurrency?: number
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
type AffinityLevelDialogProps = {
|
||||
mode?: "create" | "edit"
|
||||
level?: AffinityLevel
|
||||
onSave?: (level: AffinityLevel) => void
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AffinityLevelDialog({ mode = "create", level, onSave, trigger }: AffinityLevelDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// 表单状态
|
||||
const [levelNumber, setLevelNumber] = useState("6")
|
||||
const [levelName, setLevelName] = useState("")
|
||||
const [minAffinity, setMinAffinity] = useState("101")
|
||||
const [maxAffinity, setMaxAffinity] = useState("120")
|
||||
const [unlockContent, setUnlockContent] = useState("")
|
||||
const [rewardType, setRewardType] = useState("unlock")
|
||||
const [rewardItems, setRewardItems] = useState("")
|
||||
const [rewardCurrency, setRewardCurrency] = useState("100")
|
||||
const [isEnabled, setIsEnabled] = useState(true)
|
||||
|
||||
// 当编辑模式且有等级数据时,初始化表单
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && level) {
|
||||
setLevelNumber(String(level.level))
|
||||
setLevelName(level.name)
|
||||
setMinAffinity(String(level.minAffinity))
|
||||
setMaxAffinity(String(level.maxAffinity))
|
||||
setUnlockContent(level.unlockContent)
|
||||
setRewardType(level.rewardType)
|
||||
setRewardItems(level.rewardItems || "")
|
||||
setRewardCurrency(String(level.rewardCurrency || 100))
|
||||
setIsEnabled(level.isEnabled)
|
||||
}
|
||||
}, [mode, level, open])
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
if (mode === "create") {
|
||||
setLevelNumber("6")
|
||||
setLevelName("")
|
||||
setMinAffinity("101")
|
||||
setMaxAffinity("120")
|
||||
setUnlockContent("")
|
||||
setRewardType("unlock")
|
||||
setRewardItems("")
|
||||
setRewardCurrency("100")
|
||||
setIsEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!levelName || !unlockContent) {
|
||||
alert("请填写所有必填字段!")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
// 构建等级对象
|
||||
const updatedLevel: AffinityLevel = {
|
||||
id: level?.id || `level-${Date.now()}`,
|
||||
level: Number(levelNumber),
|
||||
name: levelName,
|
||||
minAffinity: Number(minAffinity),
|
||||
maxAffinity: Number(maxAffinity),
|
||||
unlockContent,
|
||||
rewardType,
|
||||
rewardItems: rewardItems || undefined,
|
||||
rewardCurrency: rewardType === "currency" ? Number(rewardCurrency) : undefined,
|
||||
isEnabled,
|
||||
}
|
||||
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 调用保存回调
|
||||
if (onSave) {
|
||||
onSave(updatedLevel)
|
||||
}
|
||||
|
||||
// 成功提示
|
||||
alert(mode === "create" ? "好感度等级创建成功!" : "好感度等级更新成功!")
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
// 错误处理
|
||||
console.error(mode === "create" ? "创建好感度等级失败:" : "更新好感度等级失败:", error)
|
||||
alert(mode === "create" ? "创建好感度等级失败,请重试!" : "更新好感度等级失败,请重试!")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
setOpen(newOpen)
|
||||
if (!newOpen) resetForm()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<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>
|
||||
{mode === "create" ? "设置新的好感度等级和对应奖励" : "修改好感度等级和对应奖励"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="level" className="text-right">
|
||||
等级编号 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="level"
|
||||
type="number"
|
||||
min="1"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={levelNumber}
|
||||
onChange={(e) => setLevelNumber(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="level-name" className="text-right">
|
||||
等级名称 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="level-name"
|
||||
placeholder="输入等级名称"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={levelName}
|
||||
onChange={(e) => setLevelName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="min-affinity" className="text-right">
|
||||
最小好感度 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
id="min-affinity"
|
||||
type="number"
|
||||
min="0"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={minAffinity}
|
||||
onChange={(e) => setMinAffinity(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<span className="text-sm text-gray-500">点</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-affinity" className="text-right">
|
||||
最大好感度 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
id="max-affinity"
|
||||
type="number"
|
||||
min="0"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={maxAffinity}
|
||||
onChange={(e) => setMaxAffinity(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<span className="text-sm text-gray-500">点</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="unlock-content" className="text-right">
|
||||
解锁内容 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="unlock-content"
|
||||
placeholder="输入该等级解锁的内容"
|
||||
className="min-h-[80px] border-gray-300 focus-visible:ring-pink-500"
|
||||
value={unlockContent}
|
||||
onChange={(e) => setUnlockContent(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reward-type" className="text-right">
|
||||
奖励类型
|
||||
</Label>
|
||||
<Select value={rewardType} onValueChange={setRewardType}>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unlock">内容解锁</SelectItem>
|
||||
<SelectItem value="item">道具奖励</SelectItem>
|
||||
<SelectItem value="currency">虚拟货币</SelectItem>
|
||||
<SelectItem value="mixed">混合奖励</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{rewardType === "item" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reward-items" className="text-right">
|
||||
奖励道具
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reward-items"
|
||||
placeholder="输入奖励的道具列表"
|
||||
className="min-h-[80px] border-gray-300 focus-visible:ring-pink-500"
|
||||
value={rewardItems}
|
||||
onChange={(e) => setRewardItems(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rewardType === "currency" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reward-currency" className="text-right">
|
||||
虚拟货币数量
|
||||
</Label>
|
||||
<Input
|
||||
id="reward-currency"
|
||||
type="number"
|
||||
min="0"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={rewardCurrency}
|
||||
onChange={(e) => setRewardCurrency(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="enable-level" checked={isEnabled} onCheckedChange={setIsEnabled} />
|
||||
<Label htmlFor="enable-level" className="cursor-pointer">
|
||||
{mode === "create" ? "立即启用此等级" : "启用此等级"}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<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 ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{mode === "create" ? "创建中..." : "更新中..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Award className="mr-2 h-4 w-4" />
|
||||
{mode === "create" ? "创建等级" : "更新等级"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
321
qy-lty-admin/components/affinity/affinity-rule-dialog.tsx
Normal file
321
qy-lty-admin/components/affinity/affinity-rule-dialog.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
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, Loader2, Heart } from "lucide-react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
// 定义互动规则类型
|
||||
export type AffinityRule = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
description: string
|
||||
minChange: number
|
||||
maxChange: number
|
||||
singleCap: number
|
||||
dailyCap: number
|
||||
isNegative: boolean
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
type AffinityRuleDialogProps = {
|
||||
mode?: "create" | "edit"
|
||||
rule?: AffinityRule
|
||||
onSave?: (rule: AffinityRule) => void
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AffinityRuleDialog({ mode = "create", rule, onSave, trigger }: AffinityRuleDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// 表单状态
|
||||
const [name, setName] = useState("")
|
||||
const [ruleType, setRuleType] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [minChange, setMinChange] = useState("1")
|
||||
const [maxChange, setMaxChange] = useState("5")
|
||||
const [singleCap, setSingleCap] = useState("5")
|
||||
const [dailyCap, setDailyCap] = useState("15")
|
||||
const [isNegative, setIsNegative] = useState(false)
|
||||
const [isEnabled, setIsEnabled] = useState(true)
|
||||
|
||||
// 当编辑模式且有规则数据时,初始化表单
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && rule) {
|
||||
setName(rule.name)
|
||||
setRuleType(rule.type)
|
||||
setDescription(rule.description)
|
||||
setMinChange(String(rule.minChange))
|
||||
setMaxChange(String(rule.maxChange))
|
||||
setSingleCap(String(rule.singleCap))
|
||||
setDailyCap(String(rule.dailyCap))
|
||||
setIsNegative(rule.isNegative)
|
||||
setIsEnabled(rule.isEnabled)
|
||||
}
|
||||
}, [mode, rule, open])
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
if (mode === "create") {
|
||||
setName("")
|
||||
setRuleType("")
|
||||
setDescription("")
|
||||
setMinChange("1")
|
||||
setMaxChange("5")
|
||||
setSingleCap("5")
|
||||
setDailyCap("15")
|
||||
setIsNegative(false)
|
||||
setIsEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!name || !ruleType || !description) {
|
||||
alert("请填写所有必填字段!")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
// 构建规则对象
|
||||
const updatedRule: AffinityRule = {
|
||||
id: rule?.id || `rule-${Date.now()}`,
|
||||
name,
|
||||
type: ruleType,
|
||||
description,
|
||||
minChange: Number(minChange),
|
||||
maxChange: Number(maxChange),
|
||||
singleCap: Number(singleCap),
|
||||
dailyCap: Number(dailyCap),
|
||||
isNegative,
|
||||
isEnabled,
|
||||
}
|
||||
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 调用保存回调
|
||||
if (onSave) {
|
||||
onSave(updatedRule)
|
||||
}
|
||||
|
||||
// 成功提示
|
||||
alert(mode === "create" ? "互动规则创建成功!" : "互动规则更新成功!")
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
// 错误处理
|
||||
console.error(mode === "create" ? "创建互动规则失败:" : "更新互动规则失败:", error)
|
||||
alert(mode === "create" ? "创建互动规则失败,请重试!" : "更新互动规则失败,请重试!")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
setOpen(newOpen)
|
||||
if (!newOpen) resetForm()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<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>
|
||||
{mode === "create" ? "设置新的互动行为对好感度的影响规则" : "修改互动行为对好感度的影响规则"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-name" className="text-right">
|
||||
规则名称 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="rule-name"
|
||||
placeholder="输入规则名称"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-type" className="text-right">
|
||||
互动类型 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={ruleType} onValueChange={setRuleType} required>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue placeholder="选择互动类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="card">使用<EFBFBD><EFBFBD>片</SelectItem>
|
||||
<SelectItem value="chat">对话</SelectItem>
|
||||
<SelectItem value="feed">喂食</SelectItem>
|
||||
<SelectItem value="touch">抚摸</SelectItem>
|
||||
<SelectItem value="dress">换装</SelectItem>
|
||||
<SelectItem value="prop">使用道具</SelectItem>
|
||||
<SelectItem value="gift">送礼物</SelectItem>
|
||||
<SelectItem value="decay">无互动衰减</SelectItem>
|
||||
<SelectItem value="custom">自定义</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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-[80px] border-gray-300 focus-visible:ring-pink-500"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="min-change" className="text-right">
|
||||
最小变化值 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
id="min-change"
|
||||
type="number"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={minChange}
|
||||
onChange={(e) => setMinChange(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<span className="text-sm text-gray-500">点</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-change" className="text-right">
|
||||
最大变化值 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
id="max-change"
|
||||
type="number"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={maxChange}
|
||||
onChange={(e) => setMaxChange(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<span className="text-sm text-gray-500">点</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="single-cap" className="text-right">
|
||||
单次上限 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
id="single-cap"
|
||||
type="number"
|
||||
min="0"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={singleCap}
|
||||
onChange={(e) => setSingleCap(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<span className="text-sm text-gray-500">点</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="daily-cap" className="text-right">
|
||||
每日上限 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
id="daily-cap"
|
||||
type="number"
|
||||
min="0"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={dailyCap}
|
||||
onChange={(e) => setDailyCap(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<span className="text-sm text-gray-500">点</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="negative" checked={isNegative} onCheckedChange={setIsNegative} />
|
||||
<Label htmlFor="negative" className="cursor-pointer">
|
||||
这是负面规则(减少好感度)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="enable-rule" checked={isEnabled} onCheckedChange={setIsEnabled} />
|
||||
<Label htmlFor="enable-rule" className="cursor-pointer">
|
||||
{mode === "create" ? "立即启用此规则" : "启用此规则"}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<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 ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{mode === "create" ? "创建中..." : "更新中..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Heart className="mr-2 h-4 w-4" />
|
||||
{mode === "create" ? "创建规则" : "更新规则"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
351
qy-lty-admin/components/dances/add-dance-dialog.tsx
Normal file
351
qy-lty-admin/components/dances/add-dance-dialog.tsx
Normal file
@ -0,0 +1,351 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import type { Dance } from "@/lib/api/types"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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"
|
||||
|
||||
interface AddDanceDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onDanceAdded: (dance: Dance) => void
|
||||
editDance?: Dance
|
||||
}
|
||||
|
||||
export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }: AddDanceDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<Partial<Dance>>({
|
||||
name: "",
|
||||
choreographer: "",
|
||||
duration: "",
|
||||
difficulty: "中等",
|
||||
description: "",
|
||||
category: "流行",
|
||||
tags: [],
|
||||
motionFile: "",
|
||||
videoUrl: "",
|
||||
})
|
||||
|
||||
// 预览ID
|
||||
const [previewId, setPreviewId] = useState(
|
||||
"DNC" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
|
||||
// 当编辑模式且有舞蹈数据时,初始化表单
|
||||
useEffect(() => {
|
||||
if (editDance) {
|
||||
setFormData({
|
||||
name: editDance.name || "",
|
||||
choreographer: editDance.choreographer || "",
|
||||
duration: editDance.duration || "",
|
||||
difficulty: editDance.difficulty || "中等",
|
||||
description: editDance.description || "",
|
||||
category: editDance.category || "流行",
|
||||
tags: editDance.tags || [],
|
||||
motionFile: editDance.motionFile || "",
|
||||
videoUrl: editDance.videoUrl || "",
|
||||
})
|
||||
} else {
|
||||
// 重置表单
|
||||
setFormData({
|
||||
name: "",
|
||||
choreographer: "",
|
||||
duration: "",
|
||||
difficulty: "中等",
|
||||
description: "",
|
||||
category: "流行",
|
||||
tags: [],
|
||||
motionFile: "",
|
||||
videoUrl: "",
|
||||
})
|
||||
// 生成新的预览ID
|
||||
setPreviewId(
|
||||
"DNC" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
}
|
||||
}, [editDance, open])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleSelectChange = (name: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleTagsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const tagsString = e.target.value
|
||||
const tagsArray = tagsString
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag !== "")
|
||||
setFormData((prev) => ({ ...prev, tags: tagsArray }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// 表单验证
|
||||
if (!formData.name) {
|
||||
toast({
|
||||
title: "表单不完整",
|
||||
description: "请填写舞蹈名称",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
// 模拟API请求延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
const newDance: Dance = {
|
||||
id: editDance?.id || previewId,
|
||||
name: formData.name || "",
|
||||
choreographer: formData.choreographer || "",
|
||||
duration: formData.duration || "",
|
||||
difficulty: formData.difficulty || "中等",
|
||||
description: formData.description || "",
|
||||
category: formData.category || "流行",
|
||||
tags: formData.tags || [],
|
||||
motionFile: formData.motionFile || "",
|
||||
videoUrl: formData.videoUrl || "",
|
||||
coverUrl: editDance?.coverUrl || "/placeholder.svg?height=300&width=400",
|
||||
createdAt: editDance?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
onDanceAdded(newDance)
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "操作失败",
|
||||
description: "添加或更新舞蹈时出现错误,请重试。",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
{editDance ? "编辑舞蹈" : "添加新舞蹈"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editDance ? "修改舞蹈信息,完成后点击保存。" : "填写舞蹈信息,完成后点击添加。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
舞蹈名称 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name || ""}
|
||||
onChange={handleChange}
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="choreographer">编舞者</Label>
|
||||
<Input
|
||||
id="choreographer"
|
||||
name="choreographer"
|
||||
value={formData.choreographer || ""}
|
||||
onChange={handleChange}
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duration">时长</Label>
|
||||
<Input
|
||||
id="duration"
|
||||
name="duration"
|
||||
value={formData.duration || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="例如: 3:45"
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="difficulty">难度</Label>
|
||||
<Select
|
||||
value={formData.difficulty || "中等"}
|
||||
onValueChange={(value) => handleSelectChange("difficulty", value)}
|
||||
>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-purple-500">
|
||||
<SelectValue placeholder="选择难度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="初级">初级</SelectItem>
|
||||
<SelectItem value="中等">中等</SelectItem>
|
||||
<SelectItem value="中高级">中高级</SelectItem>
|
||||
<SelectItem value="高级">高级</SelectItem>
|
||||
<SelectItem value="专业">专业</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">分类</Label>
|
||||
<Select
|
||||
value={formData.category || "流行"}
|
||||
onValueChange={(value) => handleSelectChange("category", value)}
|
||||
>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-purple-500">
|
||||
<SelectValue placeholder="选择分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="流行">流行</SelectItem>
|
||||
<SelectItem value="中国风">中国风</SelectItem>
|
||||
<SelectItem value="日式">日式</SelectItem>
|
||||
<SelectItem value="现代">现代</SelectItem>
|
||||
<SelectItem value="古典">古典</SelectItem>
|
||||
<SelectItem value="街舞">街舞</SelectItem>
|
||||
<SelectItem value="其他">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tags">标签</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
name="tags"
|
||||
value={formData.tags?.join(", ") || ""}
|
||||
onChange={handleTagsChange}
|
||||
placeholder="用逗号分隔多个标签"
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motionFile">动作文件</Label>
|
||||
<Input
|
||||
id="motionFile"
|
||||
name="motionFile"
|
||||
value={formData.motionFile || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="动作文件名称,例如: dance_motion.fbx"
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="videoUrl">视频链接</Label>
|
||||
<Input
|
||||
id="videoUrl"
|
||||
name="videoUrl"
|
||||
value={formData.videoUrl || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="舞蹈视频链接"
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">舞蹈描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description || ""}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
placeholder="请输入舞蹈的详细描述..."
|
||||
className="min-h-[100px] border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{!editDance && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="text-sm font-medium mb-2">预览信息</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
舞蹈ID: <span className="font-medium text-purple-600">{previewId}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{editDance ? "更新中..." : "创建中..."}
|
||||
</>
|
||||
) : editDance ? (
|
||||
"保存修改"
|
||||
) : (
|
||||
"添加舞蹈"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
154
qy-lty-admin/components/dances/dance-detail-dialog.tsx
Normal file
154
qy-lty-admin/components/dances/dance-detail-dialog.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import type { Dance } from "@/lib/api/types"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Clock, Music, Calendar, User, BarChart, Video, Play, Pause } from "lucide-react"
|
||||
|
||||
interface DanceDetailDialogProps {
|
||||
dance: Dance
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function DanceDetailDialog({ dance, open, onOpenChange }: DanceDetailDialogProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
||||
const togglePlay = () => {
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
{dance.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>舞蹈ID: {dance.id}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="relative aspect-video bg-gray-100 rounded-lg flex items-center justify-center group overflow-hidden">
|
||||
{dance.coverUrl ? (
|
||||
<img
|
||||
src={dance.coverUrl || "/placeholder.svg"}
|
||||
alt={dance.name}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
onError={(e) => {
|
||||
// 如果图片加载失败,使用占位符
|
||||
e.currentTarget.src = "/placeholder.svg?height=300&width=400"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400 flex flex-col items-center">
|
||||
<Video className="h-10 w-10 mb-2" />
|
||||
<span>舞蹈封面</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-12 w-12 text-white" /> : <Play className="h-12 w-12 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dance.tags?.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="bg-purple-50 text-purple-700">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm text-gray-500">编舞者:</span>
|
||||
<span className="font-medium">{dance.choreographer || "未知"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm text-gray-500">时长:</span>
|
||||
<span className="font-medium">{dance.duration || "未知"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm text-gray-500">难度:</span>
|
||||
<span className="font-medium">{dance.difficulty || "未知"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm text-gray-500">分类:</span>
|
||||
<span className="font-medium">{dance.category || "未分类"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm text-gray-500">创建时间:</span>
|
||||
<span className="font-medium">
|
||||
{dance.createdAt ? new Date(dance.createdAt).toLocaleDateString() : "未知"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm text-gray-500">更新时间:</span>
|
||||
<span className="font-medium">
|
||||
{dance.updatedAt ? new Date(dance.updatedAt).toLocaleDateString() : "未知"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">动作文件:</h3>
|
||||
<div className="p-2 bg-gray-50 rounded border border-gray-200">
|
||||
{dance.motionFile || "未上传动作文件"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">舞蹈描述:</h3>
|
||||
<div className="p-3 bg-gray-50 rounded border border-gray-200 text-sm">
|
||||
{dance.description || "暂无描述"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
||||
onClick={() => {
|
||||
// 这里可以添加查看更多详情的逻辑
|
||||
onOpenChange(false)
|
||||
// 可以导航到详情页
|
||||
// router.push(`/dances/${dance.id}`)
|
||||
}}
|
||||
>
|
||||
查看更多详情
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
20
qy-lty-admin/components/dashboard-header.tsx
Normal file
20
qy-lty-admin/components/dashboard-header.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import type React from "react"
|
||||
interface DashboardHeaderProps {
|
||||
heading: string
|
||||
text?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function DashboardHeader({ heading, text, children }: DashboardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 mb-8">
|
||||
<div className="grid gap-1">
|
||||
<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>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
qy-lty-admin/components/dashboard-shell.tsx
Normal file
16
qy-lty-admin/components/dashboard-shell.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Sidebar } from "@/components/sidebar"
|
||||
|
||||
interface DashboardShellProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function DashboardShell({ children, className, ...props }: DashboardShellProps) {
|
||||
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>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
qy-lty-admin/components/delete-confirmation-dialog.tsx
Normal file
97
qy-lty-admin/components/delete-confirmation-dialog.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { AlertTriangle, Loader2, Trash2 } from "lucide-react"
|
||||
|
||||
type DeleteConfirmationDialogProps = {
|
||||
title: string
|
||||
description: string
|
||||
itemName: string
|
||||
onDelete: () => Promise<void>
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function DeleteConfirmationDialog({
|
||||
title,
|
||||
description,
|
||||
itemName,
|
||||
onDelete,
|
||||
trigger,
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await onDelete()
|
||||
setOpen(false)
|
||||
} catch (error) {
|
||||
console.error("删除失败:", error)
|
||||
alert("删除失败,请重试!")
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="ghost" size="icon" className="hover:bg-red-50 hover:text-red-600">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-red-600 flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 mr-2" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-gray-700">
|
||||
您确定要删除 <span className="font-medium text-red-600">{itemName}</span> 吗?此操作无法撤销。
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isDeleting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
删除中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
确认删除
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
446
qy-lty-admin/components/food/add-food-dialog.tsx
Normal file
446
qy-lty-admin/components/food/add-food-dialog.tsx
Normal file
@ -0,0 +1,446 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
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, AlertTriangle, Loader2 } from "lucide-react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { FoodMediaUpload } from "./food-media-upload"
|
||||
import { createFood, updateFood } from "@/lib/api/food"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import type { Food } from "./food-detail-dialog"
|
||||
|
||||
type AddFoodDialogProps = {
|
||||
mode?: "create" | "edit"
|
||||
initialFood?: Food
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSave?: (food: Food) => void
|
||||
}
|
||||
|
||||
export function AddFoodDialog({
|
||||
mode = "create",
|
||||
initialFood,
|
||||
open: controlledOpen,
|
||||
onOpenChange: setControlledOpen,
|
||||
onSave,
|
||||
}: AddFoodDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// 表单状态
|
||||
const [name, setName] = useState("")
|
||||
const [foodType, setFoodType] = useState("")
|
||||
const [rarity, setRarity] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [calories, setCalories] = useState<number | undefined>()
|
||||
const [tasteTags, setTasteTags] = useState("")
|
||||
const [nutritionalValue, setNutritionalValue] = useState("")
|
||||
const [effectDescription, setEffectDescription] = useState("")
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [imageUrl, setImageUrl] = useState<string | undefined>()
|
||||
const [animationUrl, setAnimationUrl] = useState<string | undefined>()
|
||||
const [audioUrl, setAudioUrl] = useState<string | undefined>()
|
||||
const [previewId, setPreviewId] = useState(
|
||||
"FOD" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
|
||||
// 当编辑模式且有食物数据时,初始化表单
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && initialFood) {
|
||||
console.log("🔧 初始化编辑模式表单:", initialFood)
|
||||
setName(initialFood.name)
|
||||
setFoodType(initialFood.food_type)
|
||||
setRarity(initialFood.rarity)
|
||||
setDescription(initialFood.description)
|
||||
setCalories(initialFood.calories)
|
||||
setTasteTags(initialFood.taste_tags || "")
|
||||
setNutritionalValue(initialFood.nutritional_value || "")
|
||||
setEffectDescription(initialFood.effect_description || "")
|
||||
setIsLimited(initialFood.food_type?.includes("限定") || false)
|
||||
setPreviewId(initialFood.id)
|
||||
setImageUrl(initialFood.image)
|
||||
setAnimationUrl(initialFood.animation_file)
|
||||
setAudioUrl(initialFood.sound_effect)
|
||||
}
|
||||
}, [mode, initialFood])
|
||||
|
||||
// 处理受控和非受控开关状态
|
||||
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("")
|
||||
setFoodType("")
|
||||
setRarity("")
|
||||
setDescription("")
|
||||
setCalories(undefined)
|
||||
setTasteTags("")
|
||||
setNutritionalValue("")
|
||||
setEffectDescription("")
|
||||
setIsLimited(false)
|
||||
setImageUrl(undefined)
|
||||
setAnimationUrl(undefined)
|
||||
setAudioUrl(undefined)
|
||||
setPreviewId(
|
||||
"FOD" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!name || !foodType || !rarity || !description) {
|
||||
toast({
|
||||
title: "表单验证失败",
|
||||
description: "请填写所有必填字段!",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 图片是必填的(至少在创建模式下)
|
||||
if (mode === "create" && !imageUrl) {
|
||||
toast({
|
||||
title: "表单验证失败",
|
||||
description: "请上传食物图片!",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
// 构建食物数据
|
||||
const foodData: Partial<Food> = {
|
||||
name,
|
||||
food_type: isLimited ? `限定${foodType}` : foodType,
|
||||
rarity,
|
||||
description,
|
||||
image: imageUrl,
|
||||
animation_file: animationUrl,
|
||||
sound_effect: audioUrl,
|
||||
calories,
|
||||
taste_tags: tasteTags,
|
||||
nutritional_value: nutritionalValue,
|
||||
effect_description: effectDescription,
|
||||
status: "published",
|
||||
}
|
||||
|
||||
let result: Food
|
||||
|
||||
if (mode === "create") {
|
||||
// 创建新食物
|
||||
const response = await createFood(foodData)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || "创建食物失败")
|
||||
}
|
||||
result = response.data
|
||||
console.log("✅ 食物创建成功:", result)
|
||||
} else {
|
||||
// 更新食物
|
||||
if (!initialFood?.id) {
|
||||
throw new Error("缺少食物ID,无法更新")
|
||||
}
|
||||
console.log("🔄 更新食物数据:", { id: initialFood.id, foodData })
|
||||
const response = await updateFood(initialFood.id, foodData)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || "更新食物失败")
|
||||
}
|
||||
result = response.data
|
||||
console.log("✅ 食物更新成功:", result)
|
||||
}
|
||||
|
||||
// 调用保存回调
|
||||
if (onSave) {
|
||||
onSave(result)
|
||||
}
|
||||
|
||||
// 显示成功消息
|
||||
toast({
|
||||
title: mode === "create" ? "创建成功" : "更新成功",
|
||||
description: `食物 ${name} 已${mode === "create" ? "创建" : "更新"}成功!`,
|
||||
})
|
||||
|
||||
// 关闭对话框
|
||||
handleOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error(mode === "create" ? "创建食物失败:" : "更新食物失败:", error)
|
||||
const errorMessage = error instanceof Error ? error.message : "操作失败,请重试!"
|
||||
toast({
|
||||
title: mode === "create" ? "创建失败" : "更新失败",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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] max-h-[80vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-2 flex-shrink-0">
|
||||
<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>
|
||||
{mode === "create" ? "填写食物信息以创建新的食物卡牌。创建后将生成唯一的卡牌ID。" : "修改食物信息。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 px-6 py-4 flex-1 overflow-y-auto">
|
||||
<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"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-right">
|
||||
食物类型 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={foodType} onValueChange={setFoodType} required>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue placeholder="选择食物类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fruit">水果</SelectItem>
|
||||
<SelectItem value="dessert">甜点</SelectItem>
|
||||
<SelectItem value="drink">饮品</SelectItem>
|
||||
<SelectItem value="snack">零食</SelectItem>
|
||||
<SelectItem value="main_dish">主食</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="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="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="calories" className="text-right">
|
||||
卡路里
|
||||
</Label>
|
||||
<Input
|
||||
id="calories"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="输入卡路里值"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={calories || ""}
|
||||
onChange={(e) => setCalories(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tasteTags" className="text-right">
|
||||
口味标签
|
||||
</Label>
|
||||
<Input
|
||||
id="tasteTags"
|
||||
placeholder="如:甜,脆,清爽"
|
||||
className="border-gray-300 focus-visible:ring-pink-500"
|
||||
value={tasteTags}
|
||||
onChange={(e) => setTasteTags(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nutritionalValue" className="text-right">
|
||||
营养价值
|
||||
</Label>
|
||||
<Textarea
|
||||
id="nutritionalValue"
|
||||
placeholder="描述食物的营养价值"
|
||||
className="min-h-[80px] border-gray-300 focus-visible:ring-pink-500"
|
||||
value={nutritionalValue}
|
||||
onChange={(e) => setNutritionalValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="effectDescription" className="text-right">
|
||||
功效描述
|
||||
</Label>
|
||||
<Textarea
|
||||
id="effectDescription"
|
||||
placeholder="描述食物的功效和作用"
|
||||
className="min-h-[80px] border-gray-300 focus-visible:ring-pink-500"
|
||||
value={effectDescription}
|
||||
onChange={(e) => setEffectDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-right">
|
||||
食物媒体文件 {mode === "create" && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<FoodMediaUpload
|
||||
imageUrl={imageUrl}
|
||||
animationUrl={animationUrl}
|
||||
audioUrl={audioUrl}
|
||||
onImageUpload={setImageUrl}
|
||||
onAnimationUpload={setAnimationUrl}
|
||||
onAudioUpload={setAudioUrl}
|
||||
onRemove={(type) => {
|
||||
if (type === 'image') setImageUrl(undefined)
|
||||
else if (type === 'animation') setAnimationUrl(undefined)
|
||||
else if (type === 'audio') setAudioUrl(undefined)
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{mode === "create" && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="text-sm font-medium mb-2">预览信息</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
食物ID: <span className="font-medium text-pink-600">{previewId}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
状态: <span className="font-medium">未发布</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="px-6 py-4 border-t flex-shrink-0">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
152
qy-lty-admin/components/food/add-print-batch-dialog.tsx
Normal file
152
qy-lty-admin/components/food/add-print-batch-dialog.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Plus, Loader2 } from "lucide-react"
|
||||
|
||||
interface AddPrintBatchDialogProps {
|
||||
foodId: string
|
||||
isPublished: boolean
|
||||
}
|
||||
|
||||
export function AddPrintBatchDialog({ foodId, isPublished }: AddPrintBatchDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [quantity, setQuantity] = useState(1000)
|
||||
const [batchName, setBatchName] = useState("")
|
||||
const [printMethod, setPrintMethod] = useState("standard")
|
||||
const [autoActivate, setAutoActivate] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
setIsSubmitting(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 transition-all duration-300 shadow-md hover:shadow-lg">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加印刷批次
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
添加新印刷批次
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
为食物 <span className="font-medium">{foodId}</span> 添加新的卡牌印刷批次。系统将自动生成唯一的卡牌ID。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batchName">批次名称</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
placeholder="例如:2024年春季批次"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="printMethod">印刷方式</Label>
|
||||
<Select value={printMethod} onValueChange={setPrintMethod}>
|
||||
<SelectTrigger id="printMethod">
|
||||
<SelectValue placeholder="选择印刷方式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">标准印刷</SelectItem>
|
||||
<SelectItem value="premium">高级印刷(带全息效果)</SelectItem>
|
||||
<SelectItem value="limited">限定版(带编号)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity">印刷数量</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Number.parseInt(e.target.value))}
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">将生成 {quantity} 个新的卡牌ID</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Checkbox
|
||||
id="autoActivate"
|
||||
checked={autoActivate}
|
||||
onCheckedChange={(checked) => setAutoActivate(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="autoActivate" className="text-sm font-normal cursor-pointer">
|
||||
自动激活(仅用于测试环境)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-right">预览</Label>
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-700">
|
||||
批次ID:{" "}
|
||||
<span className="font-mono">
|
||||
B
|
||||
{Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0")}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
起始ID: <span className="font-mono">{foodId}-XXXX</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
结束ID: <span className="font-mono">{foodId}-YYYY</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">实际ID将在创建批次时生成</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
"创建批次"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
135
qy-lty-admin/components/food/export-cards-dialog.tsx
Normal file
135
qy-lty-admin/components/food/export-cards-dialog.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { FileDown, Loader2 } from "lucide-react"
|
||||
|
||||
interface ExportCardsDialogProps {
|
||||
foodId: string
|
||||
}
|
||||
|
||||
export function ExportCardsDialog({ foodId }: ExportCardsDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [format, setFormat] = useState("csv")
|
||||
const [range, setRange] = useState("all")
|
||||
const [includeHeaders, setIncludeHeaders] = useState(true)
|
||||
const [includeMetadata, setIncludeMetadata] = useState(true)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
// 模拟导出操作
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
setIsExporting(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="border-purple-200 hover:bg-purple-50 hover:text-purple-700">
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
导出卡牌ID
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
导出卡牌ID
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择导出格式并下载食物 <span className="font-medium">{foodId}</span> 的卡牌ID。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="format">导出格式</Label>
|
||||
<Select value={format} onValueChange={setFormat}>
|
||||
<SelectTrigger id="format">
|
||||
<SelectValue placeholder="选择格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">CSV文件</SelectItem>
|
||||
<SelectItem value="excel">Excel文件</SelectItem>
|
||||
<SelectItem value="pdf">PDF文件</SelectItem>
|
||||
<SelectItem value="json">JSON文件</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="range">导出范围</Label>
|
||||
<Select value={range} onValueChange={setRange}>
|
||||
<SelectTrigger id="range">
|
||||
<SelectValue placeholder="选择范围" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">所有卡牌</SelectItem>
|
||||
<SelectItem value="unused">未激活卡牌</SelectItem>
|
||||
<SelectItem value="used">已激活卡牌</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeHeaders"
|
||||
checked={includeHeaders}
|
||||
onCheckedChange={(checked) => setIncludeHeaders(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="includeHeaders" className="text-sm font-normal cursor-pointer">
|
||||
包含表头
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeMetadata"
|
||||
checked={includeMetadata}
|
||||
onCheckedChange={(checked) => setIncludeMetadata(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="includeMetadata" className="text-sm font-normal cursor-pointer">
|
||||
包含元数据(创建时间、批次信息等)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isExporting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
导出中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
导出
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
119
qy-lty-admin/components/food/food-detail-dialog.tsx
Normal file
119
qy-lty-admin/components/food/food-detail-dialog.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Eye, Edit } from "lucide-react"
|
||||
|
||||
export type Food = {
|
||||
id: string
|
||||
name: string
|
||||
food_type: string
|
||||
description: string
|
||||
rarity: string
|
||||
image?: string
|
||||
animation_file?: string
|
||||
sound_effect?: string
|
||||
calories?: number
|
||||
taste_tags?: string
|
||||
nutritional_value?: string
|
||||
effect_description?: string
|
||||
boost_attributes?: Record<string, number>
|
||||
status: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
// 保留一些前端显示用的字段
|
||||
releaseDate?: string
|
||||
activatedCount?: number
|
||||
}
|
||||
|
||||
type FoodDetailDialogProps = {
|
||||
food: Food
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
export function FoodDetailDialog({ food, onEdit }: FoodDetailDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600">
|
||||
<Eye className="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">
|
||||
食物详情
|
||||
</DialogTitle>
|
||||
<DialogDescription>查看食物的详细信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-24 w-24 rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
|
||||
<img
|
||||
src={food.image || "/placeholder.svg?height=96&width=96"}
|
||||
alt={food.name}
|
||||
className="object-cover h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">{food.name}</h3>
|
||||
<p className="text-sm text-gray-500">ID: {food.id}</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge className="bg-purple-500">{food.food_type}</Badge>
|
||||
<Badge className="bg-blue-500">{food.rarity}</Badge>
|
||||
<Badge className={food.status === "已发布" ? "bg-green-500" : "bg-gray-500"}>{food.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">食物描述</h4>
|
||||
<p className="text-sm">{food.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">发布日期</h4>
|
||||
<p className="text-sm">{food.releaseDate || "未发布"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">激活数量</h4>
|
||||
<p className="text-sm">{food.activatedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
{food.status !== "已发布" && onEdit && (
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑食物
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
264
qy-lty-admin/components/food/food-media-upload.tsx
Normal file
264
qy-lty-admin/components/food/food-media-upload.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
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 */
|
||||
imageUrl?: string
|
||||
/** 当前动画URL */
|
||||
animationUrl?: string
|
||||
/** 当前音频URL */
|
||||
audioUrl?: string
|
||||
/** 图片上传成功回调 */
|
||||
onImageUpload?: (url: string) => void
|
||||
/** 动画上传成功回调 */
|
||||
onAnimationUpload?: (url: string) => void
|
||||
/** 音频上传成功回调 */
|
||||
onAudioUpload?: (url: string) => void
|
||||
/** 文件移除回调 */
|
||||
onRemove?: (type: 'image' | 'animation' | 'audio') => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function FoodMediaUpload({
|
||||
imageUrl,
|
||||
animationUrl,
|
||||
audioUrl,
|
||||
onImageUpload,
|
||||
onAnimationUpload,
|
||||
onAudioUpload,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
}: 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">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="image" className="flex items-center gap-2">
|
||||
<Image className="h-4 w-4" />
|
||||
图片
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="animation" className="flex items-center gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
动画
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audio" className="flex items-center gap-2">
|
||||
<Music className="h-4 w-4" />
|
||||
音频
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 图片上传 */}
|
||||
<TabsContent value="image" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Image className="h-5 w-5" />
|
||||
食物图片
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
上传食物的展示图片,支持 JPEG、PNG、GIF 格式,建议尺寸 300x300px
|
||||
</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">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="食物图片预览"
|
||||
className="h-12 w-12 object-cover rounded border"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">当前图片</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove?.('image')}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 动画上传 */}
|
||||
<TabsContent value="animation" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
食物动画
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
上传食物的动画效果,支持 MP4、GIF、Lottie 格式,最大 50MB
|
||||
</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">
|
||||
<div className="flex items-center space-x-3">
|
||||
{animationUrl.endsWith('.gif') ? (
|
||||
<img
|
||||
src={animationUrl}
|
||||
alt="动画预览"
|
||||
className="h-12 w-12 object-cover rounded border"
|
||||
/>
|
||||
) : animationUrl.match(/\.(mp4|avi|mov|wmv|flv)$/i) ? (
|
||||
<video
|
||||
src={animationUrl}
|
||||
className="h-12 w-12 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>
|
||||
)}
|
||||
<span className="text-sm text-gray-700">当前动画</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove?.('animation')}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 音频上传 */}
|
||||
<TabsContent value="audio" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Music className="h-5 w-5" />
|
||||
食物音效
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
上传食物相关的音效,支持 MP3、WAV、OGG 等格式,最大 20MB
|
||||
</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">
|
||||
<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>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-gray-700 block">当前音频</span>
|
||||
<audio controls className="mt-1 w-full max-w-xs">
|
||||
<source src={audioUrl} />
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove?.('audio')}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
310
qy-lty-admin/components/home-decor/add-home-decor-dialog.tsx
Normal file
310
qy-lty-admin/components/home-decor/add-home-decor-dialog.tsx
Normal file
@ -0,0 +1,310 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
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 { Switch } from "@/components/ui/switch"
|
||||
import type { HomeDecor } from "./home-decor-detail-dialog"
|
||||
|
||||
type AddHomeDecorDialogProps = {
|
||||
mode?: "create" | "edit"
|
||||
initialDecor?: HomeDecor
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSave?: (decor: HomeDecor) => void
|
||||
}
|
||||
|
||||
export function AddHomeDecorDialog({
|
||||
mode = "create",
|
||||
initialDecor,
|
||||
open: controlledOpen,
|
||||
onOpenChange: setControlledOpen,
|
||||
onSave,
|
||||
}: AddHomeDecorDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// 表单状态
|
||||
const [name, setName] = useState("")
|
||||
const [decorType, setDecorType] = useState("")
|
||||
const [rarity, setRarity] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [previewId, setPreviewId] = useState(
|
||||
"DEC" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
|
||||
// 当编辑模式且有装饰数据时,初始化表单
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && initialDecor) {
|
||||
setName(initialDecor.name)
|
||||
setDecorType(initialDecor.type)
|
||||
setRarity(initialDecor.rarity)
|
||||
setDescription(initialDecor.description)
|
||||
setIsLimited(initialDecor.type.includes("限定"))
|
||||
setPreviewId(initialDecor.id)
|
||||
}
|
||||
}, [mode, initialDecor])
|
||||
|
||||
// 处理受控和非受控开关状态
|
||||
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("")
|
||||
setDecorType("")
|
||||
setRarity("")
|
||||
setDescription("")
|
||||
setIsLimited(false)
|
||||
setPreviewId(
|
||||
"DEC" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!name || !decorType || !rarity || !description) {
|
||||
alert("请填写所有必填字段!")
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 调用保存回调
|
||||
if (onSave) {
|
||||
onSave(decor)
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
handleOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error(mode === "create" ? "创建家居装饰失败:" : "更新家居装饰失败:", error)
|
||||
alert(mode === "create" ? "创建家居装饰失败,请重试!" : "更新家居装饰失败,请重试!")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
{mode === "create"
|
||||
? "填写家居装饰信息以创建新的装饰卡牌。创建后将生成唯一的卡牌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"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-right">
|
||||
装饰类型 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={decorType} onValueChange={setDecorType} 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>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">点击或拖拽上传装饰图片</p>
|
||||
<p className="text-xs text-gray-400 mt-1">支持 PNG, JPG, JPEG 格式,最大 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="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>
|
||||
)}
|
||||
|
||||
{mode === "create" && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="text-sm font-medium mb-2">预览信息</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
装饰ID: <span className="font-medium text-pink-600">{previewId}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
状态: <span className="font-medium">未发布</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
152
qy-lty-admin/components/home-decor/add-print-batch-dialog.tsx
Normal file
152
qy-lty-admin/components/home-decor/add-print-batch-dialog.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Plus, Loader2 } from "lucide-react"
|
||||
|
||||
interface AddPrintBatchDialogProps {
|
||||
decorId: string
|
||||
isPublished: boolean
|
||||
}
|
||||
|
||||
export function AddPrintBatchDialog({ decorId, isPublished }: AddPrintBatchDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [quantity, setQuantity] = useState(1000)
|
||||
const [batchName, setBatchName] = useState("")
|
||||
const [printMethod, setPrintMethod] = useState("standard")
|
||||
const [autoActivate, setAutoActivate] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
setIsSubmitting(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 transition-all duration-300 shadow-md hover:shadow-lg">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加印刷批次
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
添加新印刷批次
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
为家居装饰 <span className="font-medium">{decorId}</span> 添加新的卡牌印刷批次。系统将自动生成唯一的卡牌ID。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batchName">批次名称</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
placeholder="例如:2024年春季批次"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="printMethod">印刷方式</Label>
|
||||
<Select value={printMethod} onValueChange={setPrintMethod}>
|
||||
<SelectTrigger id="printMethod">
|
||||
<SelectValue placeholder="选择印刷方式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">标准印刷</SelectItem>
|
||||
<SelectItem value="premium">高级印刷(带全息效果)</SelectItem>
|
||||
<SelectItem value="limited">限定版(带编号)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity">印刷数量</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Number.parseInt(e.target.value))}
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">将生成 {quantity} 个新的卡牌ID</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Checkbox
|
||||
id="autoActivate"
|
||||
checked={autoActivate}
|
||||
onCheckedChange={(checked) => setAutoActivate(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="autoActivate" className="text-sm font-normal cursor-pointer">
|
||||
自动激活(仅用于测试环境)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-right">预览</Label>
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-700">
|
||||
批次ID:{" "}
|
||||
<span className="font-mono">
|
||||
B
|
||||
{Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0")}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
起始ID: <span className="font-mono">{decorId}-XXXX</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
结束ID: <span className="font-mono">{decorId}-YYYY</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">实际ID将在创建批次时生成</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
"创建批次"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
135
qy-lty-admin/components/home-decor/export-cards-dialog.tsx
Normal file
135
qy-lty-admin/components/home-decor/export-cards-dialog.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { FileDown, Loader2 } from "lucide-react"
|
||||
|
||||
interface ExportCardsDialogProps {
|
||||
decorId: string
|
||||
}
|
||||
|
||||
export function ExportCardsDialog({ decorId }: ExportCardsDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [format, setFormat] = useState("csv")
|
||||
const [range, setRange] = useState("all")
|
||||
const [includeHeaders, setIncludeHeaders] = useState(true)
|
||||
const [includeMetadata, setIncludeMetadata] = useState(true)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
// 模拟导出操作
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
setIsExporting(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="border-purple-200 hover:bg-purple-50 hover:text-purple-700">
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
导出卡牌ID
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
导出卡牌ID
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择导出格式并下载家居装饰 <span className="font-medium">{decorId}</span> 的卡牌ID。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="format">导出格式</Label>
|
||||
<Select value={format} onValueChange={setFormat}>
|
||||
<SelectTrigger id="format">
|
||||
<SelectValue placeholder="选择格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">CSV文件</SelectItem>
|
||||
<SelectItem value="excel">Excel文件</SelectItem>
|
||||
<SelectItem value="pdf">PDF文件</SelectItem>
|
||||
<SelectItem value="json">JSON文件</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="range">导出范围</Label>
|
||||
<Select value={range} onValueChange={setRange}>
|
||||
<SelectTrigger id="range">
|
||||
<SelectValue placeholder="选择范围" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">所有卡牌</SelectItem>
|
||||
<SelectItem value="unused">未激活卡牌</SelectItem>
|
||||
<SelectItem value="used">已激活卡牌</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeHeaders"
|
||||
checked={includeHeaders}
|
||||
onCheckedChange={(checked) => setIncludeHeaders(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="includeHeaders" className="text-sm font-normal cursor-pointer">
|
||||
包含表头
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeMetadata"
|
||||
checked={includeMetadata}
|
||||
onCheckedChange={(checked) => setIncludeMetadata(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="includeMetadata" className="text-sm font-normal cursor-pointer">
|
||||
包含元数据(创建时间、批次信息等)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isExporting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
导出中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
导出
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
106
qy-lty-admin/components/home-decor/home-decor-detail-dialog.tsx
Normal file
106
qy-lty-admin/components/home-decor/home-decor-detail-dialog.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Eye, Edit } from "lucide-react"
|
||||
|
||||
export type HomeDecor = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
rarity: string
|
||||
description: string
|
||||
releaseDate: string
|
||||
status: string
|
||||
activatedCount: number
|
||||
image?: string
|
||||
}
|
||||
|
||||
type HomeDecorDetailDialogProps = {
|
||||
decor: HomeDecor
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
export function HomeDecorDetailDialog({ decor, onEdit }: HomeDecorDetailDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600">
|
||||
<Eye className="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">
|
||||
家居装饰详情
|
||||
</DialogTitle>
|
||||
<DialogDescription>查看家居装饰的详细信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-24 w-24 rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
|
||||
{/* 使用固定的占位图像,避免使用可能不存在的图片URL */}
|
||||
<div className="h-full w-full bg-gray-200 flex items-center justify-center text-gray-400">装饰图片</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">{decor.name}</h3>
|
||||
<p className="text-sm text-gray-500">ID: {decor.id}</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge className="bg-purple-500">{decor.type}</Badge>
|
||||
<Badge className="bg-blue-500">{decor.rarity}</Badge>
|
||||
<Badge className={decor.status === "已发布" ? "bg-green-500" : "bg-gray-500"}>{decor.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">装饰描述</h4>
|
||||
<p className="text-sm">{decor.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">发布日期</h4>
|
||||
<p className="text-sm">{decor.releaseDate || "未发布"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">激活数量</h4>
|
||||
<p className="text-sm">{decor.activatedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
{decor.status !== "已发布" && onEdit && (
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑装饰
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
345
qy-lty-admin/components/outfits/add-outfit-dialog.tsx
Normal file
345
qy-lty-admin/components/outfits/add-outfit-dialog.tsx
Normal file
@ -0,0 +1,345 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
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 { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function AddOutfitDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(1)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [outfitType, setOutfitType] = useState("")
|
||||
const [rarity, setRarity] = useState("")
|
||||
const [printQuantity, setPrintQuantity] = useState(1000)
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [previewId, setPreviewId] = useState(
|
||||
"OFT" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
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"),
|
||||
)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
setStep(step + 1)
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
setStep(step - 1)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
setStep(1)
|
||||
}
|
||||
|
||||
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]">
|
||||
<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>
|
||||
|
||||
<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="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 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>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">点击或拖拽上传服装图片</p>
|
||||
<p className="text-xs text-gray-400 mt-1">支持 PNG, JPG, JPEG 格式,最大 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label className="text-right">服装预览图 (可选)</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">点击或拖拽上传服装预览图</p>
|
||||
<p className="text-xs text-gray-400 mt-1">建议上传多角度预览图,最多5张</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start mt-2">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium">图片要求</p>
|
||||
<p>主图将用于卡牌印刷和APP展示,请确保图片清晰、比例合适。建议使用透明背景的PNG格式。</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step-3" className="space-y-4 py-4">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
93
qy-lty-admin/components/outfits/add-print-batch-dialog.tsx
Normal file
93
qy-lty-admin/components/outfits/add-print-batch-dialog.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
interface AddPrintBatchDialogProps {
|
||||
outfitId: string
|
||||
isPublished: boolean
|
||||
}
|
||||
|
||||
export function AddPrintBatchDialog({ outfitId, isPublished }: AddPrintBatchDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [quantity, setQuantity] = useState(1000)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-gradient-to-r from-blue-500 to-teal-500 hover:from-blue-600 hover:to-teal-600 transition-all duration-300 shadow-md hover:shadow-lg">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加印刷批次
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-teal-600">
|
||||
添加新印刷批次
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
为服装 <span className="font-medium">{outfitId}</span> 添加新的卡牌印刷批次。系统将自动生成唯一的卡牌ID。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity" className="text-right">
|
||||
印刷数量
|
||||
</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Number.parseInt(e.target.value))}
|
||||
className="border-gray-300 focus-visible:ring-blue-500"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">将生成 {quantity} 个新的卡牌ID</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-right">预览</Label>
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-700">
|
||||
批次ID:{" "}
|
||||
<span className="font-mono">
|
||||
B
|
||||
{Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0")}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
起始ID: <span className="font-mono">{outfitId}-XXXX</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
结束ID: <span className="font-mono">{outfitId}-YYYY</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">实际ID将在创建批次时生成</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="bg-gradient-to-r from-blue-500 to-teal-500 hover:from-blue-600 hover:to-teal-600">
|
||||
创建批次
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
131
qy-lty-admin/components/outfits/export-cards-dialog.tsx
Normal file
131
qy-lty-admin/components/outfits/export-cards-dialog.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Download, FileText } from "lucide-react"
|
||||
|
||||
interface ExportCardsDialogProps {
|
||||
outfitId: string
|
||||
}
|
||||
|
||||
export function ExportCardsDialog({ outfitId }: ExportCardsDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [exportType, setExportType] = useState("all")
|
||||
const [fileFormat, setFileFormat] = useState("csv")
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="hover:bg-blue-50 hover:text-blue-700 transition-all duration-200">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出卡牌ID
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-teal-600">
|
||||
导出卡牌ID
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
导出服装 <span className="font-medium">{outfitId}</span> 的卡牌ID列表。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>导出范围</Label>
|
||||
<RadioGroup defaultValue="all" value={exportType} onValueChange={setExportType}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="all" />
|
||||
<Label htmlFor="all" className="cursor-pointer">
|
||||
所有卡牌ID
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="unused" id="unused" />
|
||||
<Label htmlFor="unused" className="cursor-pointer">
|
||||
未激活的卡牌ID
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="activated" id="activated" />
|
||||
<Label htmlFor="activated" className="cursor-pointer">
|
||||
已激活的卡牌ID
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="batch" id="batch" />
|
||||
<Label htmlFor="batch" className="cursor-pointer">
|
||||
指定批次
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{exportType === "batch" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batch-select">选择批次</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-blue-500">
|
||||
<SelectValue placeholder="选择批次" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="B001">B001 (2023-09-01)</SelectItem>
|
||||
<SelectItem value="B002">B002 (2023-12-15)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="format">文件格式</Label>
|
||||
<Select value={fileFormat} onValueChange={setFileFormat}>
|
||||
<SelectTrigger className="border-gray-300 focus:ring-blue-500">
|
||||
<SelectValue placeholder="选择文件格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">CSV 文件</SelectItem>
|
||||
<SelectItem value="excel">Excel 文件</SelectItem>
|
||||
<SelectItem value="txt">文本文件</SelectItem>
|
||||
<SelectItem value="json">JSON 文件</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-50 rounded-md flex items-start">
|
||||
<FileText className="h-5 w-5 text-blue-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-blue-700 font-medium">导出预览</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
{exportType === "all" && "将导出所有卡牌ID"}
|
||||
{exportType === "unused" && "将导出所有未激活的卡牌ID"}
|
||||
{exportType === "activated" && "将导出所有已激活的卡牌ID"}
|
||||
{exportType === "batch" && "将导出选定批次的卡牌ID"} ({fileFormat.toUpperCase()} 格式)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="bg-gradient-to-r from-blue-500 to-teal-500 hover:from-blue-600 hover:to-teal-600">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
73
qy-lty-admin/components/overview.tsx
Normal file
73
qy-lty-admin/components/overview.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts"
|
||||
|
||||
const data = [
|
||||
{
|
||||
name: "1月",
|
||||
对话: 4000,
|
||||
卡牌激活: 2400,
|
||||
},
|
||||
{
|
||||
name: "2月",
|
||||
对话: 4500,
|
||||
卡牌激活: 2800,
|
||||
},
|
||||
{
|
||||
name: "3月",
|
||||
对话: 5000,
|
||||
卡牌激活: 3200,
|
||||
},
|
||||
{
|
||||
name: "4月",
|
||||
对话: 6000,
|
||||
卡牌激活: 3600,
|
||||
},
|
||||
{
|
||||
name: "5月",
|
||||
对话: 5500,
|
||||
卡牌激活: 3300,
|
||||
},
|
||||
{
|
||||
name: "6月",
|
||||
对话: 7000,
|
||||
卡牌激活: 4000,
|
||||
},
|
||||
{
|
||||
name: "7月",
|
||||
对话: 8500,
|
||||
卡牌激活: 4800,
|
||||
},
|
||||
]
|
||||
|
||||
export function Overview() {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="name" stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => `${value}`} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "white",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
|
||||
border: "none",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="对话" fill="url(#colorUv)" radius={[4, 4, 0, 0]} barSize={30} animationDuration={1500} />
|
||||
<Bar dataKey="卡牌激活" fill="url(#colorPv)" radius={[4, 4, 0, 0]} barSize={30} animationDuration={1500} />
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#d946ef" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#d946ef" stopOpacity={0.3} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorPv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0.3} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
424
qy-lty-admin/components/permissions/role-dialog.tsx
Normal file
424
qy-lty-admin/components/permissions/role-dialog.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Loader2, Shield } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
// 定义角色类型
|
||||
export type Role = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
userCount: number
|
||||
createdAt: string
|
||||
status: "系统角色" | "自定义角色"
|
||||
permissions: Record<string, boolean>
|
||||
}
|
||||
|
||||
// 定义权限组
|
||||
const permissionGroups = [
|
||||
{
|
||||
name: "仪表盘",
|
||||
permissions: [
|
||||
{ id: "dashboard.view", label: "仪表盘查看" },
|
||||
{ id: "dashboard.edit", label: "仪表盘编辑" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "用户管理",
|
||||
permissions: [
|
||||
{ id: "users.view", label: "查看用户" },
|
||||
{ id: "users.create", label: "创建用户" },
|
||||
{ id: "users.edit", label: "编辑用户" },
|
||||
{ id: "users.delete", label: "删除用户" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "角色权限",
|
||||
permissions: [
|
||||
{ id: "roles.view", label: "查看角色" },
|
||||
{ id: "roles.create", label: "创建角色" },
|
||||
{ id: "roles.edit", label: "编辑角色" },
|
||||
{ id: "roles.delete", label: "删除角色" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AI模型",
|
||||
permissions: [
|
||||
{ id: "ai.view", label: "查看AI模型" },
|
||||
{ id: "ai.create", label: "创建AI模型" },
|
||||
{ id: "ai.edit", label: "编辑AI模型" },
|
||||
{ id: "ai.delete", label: "删除AI模型" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "内容管理",
|
||||
permissions: [
|
||||
{ id: "outfits.view", label: "查看服装" },
|
||||
{ id: "outfits.create", label: "创建服装" },
|
||||
{ id: "outfits.edit", label: "编辑服装" },
|
||||
{ id: "outfits.delete", label: "删除服装" },
|
||||
{ id: "props.view", label: "查看道具" },
|
||||
{ id: "props.create", label: "创建道具" },
|
||||
{ id: "props.edit", label: "编辑道具" },
|
||||
{ id: "props.delete", label: "删除道具" },
|
||||
{ id: "homeDecor.view", label: "查看家居装饰" },
|
||||
{ id: "homeDecor.create", label: "创建家居装饰" },
|
||||
{ id: "homeDecor.edit", label: "编辑家居装饰" },
|
||||
{ id: "homeDecor.delete", label: "删除家居装饰" },
|
||||
{ id: "food.view", label: "查看食物" },
|
||||
{ id: "food.create", label: "创建食物" },
|
||||
{ id: "food.edit", label: "编辑食物" },
|
||||
{ id: "food.delete", label: "删除食物" },
|
||||
{ id: "songs.view", label: "查看歌曲" },
|
||||
{ id: "songs.create", label: "创建歌曲" },
|
||||
{ id: "songs.edit", label: "编辑歌曲" },
|
||||
{ id: "songs.delete", label: "删除歌曲" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "系统设置",
|
||||
permissions: [
|
||||
{ id: "settings.view", label: "查看设置" },
|
||||
{ id: "settings.edit", label: "编辑设置" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "好感度系统",
|
||||
permissions: [
|
||||
{ id: "affinity.view", label: "查看好感度系统" },
|
||||
{ id: "affinity.create", label: "创建好感度规则" },
|
||||
{ id: "affinity.edit", label: "编辑好感度规则" },
|
||||
{ id: "affinity.delete", label: "删除好感度规则" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 表单验证模式
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "角色名称至少需要2个字符",
|
||||
})
|
||||
.max(30, {
|
||||
message: "角色名称不能超过30个字符",
|
||||
}),
|
||||
description: z
|
||||
.string()
|
||||
.min(5, {
|
||||
message: "描述至少需要5个字符",
|
||||
})
|
||||
.max(200, {
|
||||
message: "描述不能超过200个字符",
|
||||
}),
|
||||
status: z.enum(["系统角色", "自定义角色"]),
|
||||
permissions: z.record(z.boolean()).refine(
|
||||
(data) => {
|
||||
// 至少选择一个权限
|
||||
return Object.values(data).some((value) => value === true)
|
||||
},
|
||||
{
|
||||
message: "至少需要选择一个权限",
|
||||
},
|
||||
),
|
||||
})
|
||||
|
||||
type RoleDialogProps = {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSubmit: (data: z.infer<typeof formSchema>) => Promise<void>
|
||||
defaultValues?: Partial<z.infer<typeof formSchema>>
|
||||
mode: "add" | "edit"
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function RoleDialog({ open, onOpenChange, onSubmit, defaultValues, mode, trigger }: RoleDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(open || false)
|
||||
|
||||
// 初始化所有权限为false
|
||||
const initialPermissions: Record<string, boolean> = {}
|
||||
permissionGroups.forEach((group) => {
|
||||
group.permissions.forEach((permission) => {
|
||||
initialPermissions[permission.id] = false
|
||||
})
|
||||
})
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
status: "自定义角色",
|
||||
permissions: initialPermissions,
|
||||
...defaultValues,
|
||||
},
|
||||
})
|
||||
|
||||
// 当defaultValues改变时重置表单
|
||||
useEffect(() => {
|
||||
if (defaultValues) {
|
||||
form.reset({
|
||||
name: defaultValues.name || "",
|
||||
description: defaultValues.description || "",
|
||||
status: defaultValues.status || "自定义角色",
|
||||
permissions: {
|
||||
...initialPermissions,
|
||||
...defaultValues.permissions,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [defaultValues, form])
|
||||
|
||||
// 同步内部状态和外部状态
|
||||
useEffect(() => {
|
||||
if (open !== undefined) {
|
||||
setDialogOpen(open)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setDialogOpen(newOpen)
|
||||
if (onOpenChange) {
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
// 如果对话框关闭,重置表单
|
||||
if (!newOpen) {
|
||||
form.reset()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: z.infer<typeof formSchema>) => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onSubmit(data)
|
||||
handleOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error("提交失败:", error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选某个组的所有权限
|
||||
const toggleGroupPermissions = (groupName: string, value: boolean) => {
|
||||
const group = permissionGroups.find((g) => g.name === groupName)
|
||||
if (!group) return
|
||||
|
||||
const updatedPermissions = { ...form.getValues().permissions }
|
||||
group.permissions.forEach((permission) => {
|
||||
updatedPermissions[permission.id] = value
|
||||
})
|
||||
|
||||
form.setValue("permissions", updatedPermissions, { shouldValidate: true })
|
||||
}
|
||||
|
||||
// 检查一个组是否所有权限都被选中
|
||||
const isGroupAllChecked = (groupName: string) => {
|
||||
const group = permissionGroups.find((g) => g.name === groupName)
|
||||
if (!group) return false
|
||||
|
||||
const permissions = form.getValues().permissions
|
||||
return group.permissions.every((permission) => permissions[permission.id])
|
||||
}
|
||||
|
||||
// 检查一个组是否有部分权限被选中
|
||||
const isGroupPartiallyChecked = (groupName: string) => {
|
||||
const group = permissionGroups.find((g) => g.name === groupName)
|
||||
if (!group) return false
|
||||
|
||||
const permissions = form.getValues().permissions
|
||||
const checkedCount = group.permissions.filter((permission) => permissions[permission.id]).length
|
||||
|
||||
return checkedCount > 0 && checkedCount < group.permissions.length
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<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">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
添加角色
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold flex items-center">
|
||||
<Shield className="mr-2 h-5 w-5 text-purple-600" />
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
{mode === "add" ? "添加角色" : "编辑角色"}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === "add" ? "创建新的系统角色并分配权限" : "修改角色信息和权限设置"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="basic">基本信息</TabsTrigger>
|
||||
<TabsTrigger value="permissions">权限设置</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="basic" className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>角色名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="输入角色名称" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>角色名称将显示在系统中,请使用简洁明了的名称</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>角色描述</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="输入角色描述" className="resize-none" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>详细描述该角色的职责和权限范围</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>角色类型</FormLabel>
|
||||
<div className="flex items-center space-x-4">
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="system-role"
|
||||
value="系统角色"
|
||||
checked={field.value === "系统角色"}
|
||||
onChange={() => field.onChange("系统角色")}
|
||||
className="h-4 w-4 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<label htmlFor="system-role" className="text-sm font-medium">
|
||||
系统角色
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="custom-role"
|
||||
value="自定义角色"
|
||||
checked={field.value === "自定义角色"}
|
||||
onChange={() => field.onChange("自定义角色")}
|
||||
className="h-4 w-4 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<label htmlFor="custom-role" className="text-sm font-medium">
|
||||
自定义角色
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormDescription>系统角色不可删除,自定义角色可以随时修改或删除</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="permissions" className="pt-4">
|
||||
<div className="space-y-6">
|
||||
{permissionGroups.map((group) => (
|
||||
<div key={group.name} className="border rounded-lg p-4">
|
||||
<div className="flex items-center mb-3">
|
||||
<Checkbox
|
||||
id={`group-${group.name}`}
|
||||
checked={isGroupAllChecked(group.name)}
|
||||
onCheckedChange={(checked) => {
|
||||
toggleGroupPermissions(group.name, checked === true)
|
||||
}}
|
||||
className={isGroupPartiallyChecked(group.name) ? "bg-purple-300" : ""}
|
||||
/>
|
||||
<label htmlFor={`group-${group.name}`} className="ml-2 text-sm font-medium text-purple-800">
|
||||
{group.name}
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 ml-6">
|
||||
{group.permissions.map((permission) => (
|
||||
<FormField
|
||||
key={permission.id}
|
||||
control={form.control}
|
||||
name={`permissions.${permission.id}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">{permission.label}</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
提交中...
|
||||
</>
|
||||
) : (
|
||||
<>{mode === "add" ? "创建角色" : "保存修改"}</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
165
qy-lty-admin/components/props/add-print-batch-dialog.tsx
Normal file
165
qy-lty-admin/components/props/add-print-batch-dialog.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Plus, Loader2 } from "lucide-react"
|
||||
|
||||
interface AddPrintBatchDialogProps {
|
||||
propId: string
|
||||
isPublished: boolean
|
||||
}
|
||||
|
||||
// Mock propData for demonstration purposes. Replace with actual data source.
|
||||
const propData = {
|
||||
prop1: { printedCount: 500 },
|
||||
prop2: { printedCount: 1000 },
|
||||
// ... more props
|
||||
}
|
||||
|
||||
export function AddPrintBatchDialog({ propId, isPublished }: AddPrintBatchDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [quantity, setQuantity] = useState(1000)
|
||||
const [batchName, setBatchName] = useState("")
|
||||
const [printingMethod, setPrintingMethod] = useState("standard")
|
||||
const [isAutoActivate, setIsAutoActivate] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
setIsSubmitting(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 transition-all duration-300 shadow-md hover:shadow-lg">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加印刷批次
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
添加新印刷批次
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
为道具 <span className="font-medium">{propId}</span> 添加新的卡牌印刷批次。系统将自动生成唯一的卡牌ID。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batchName">批次名称 (可选)</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
placeholder="例如:2024年春季批次"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity">印刷数量</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Number.parseInt(e.target.value))}
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">将生成 {quantity} 个新的卡牌ID</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="printingMethod">印刷方式</Label>
|
||||
<Select value={printingMethod} onValueChange={setPrintingMethod}>
|
||||
<SelectTrigger id="printingMethod">
|
||||
<SelectValue placeholder="选择印刷方式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">标准印刷</SelectItem>
|
||||
<SelectItem value="premium">高级印刷 (带全息效果)</SelectItem>
|
||||
<SelectItem value="limited">限定版 (带编号)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Checkbox
|
||||
id="autoActivate"
|
||||
checked={isAutoActivate}
|
||||
onCheckedChange={(checked) => setIsAutoActivate(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="autoActivate" className="text-sm font-normal">
|
||||
自动激活卡牌 (用于测试环境)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-right">预览</Label>
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-700">
|
||||
批次ID:{" "}
|
||||
<span className="font-mono">
|
||||
B
|
||||
{Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0")}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
起始ID:{" "}
|
||||
<span className="font-mono">
|
||||
{propId}-{(propData[propId as keyof typeof propData]?.printedCount || 0) + 1}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
结束ID:{" "}
|
||||
<span className="font-mono">
|
||||
{propId}-{(propData[propId as keyof typeof propData]?.printedCount || 0) + quantity}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">实际ID将在创建批次时生成</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
"创建批次"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
306
qy-lty-admin/components/props/add-prop-dialog.tsx
Normal file
306
qy-lty-admin/components/props/add-prop-dialog.tsx
Normal file
@ -0,0 +1,306 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
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 { Switch } from "@/components/ui/switch"
|
||||
import type { Prop } from "./prop-detail-dialog"
|
||||
|
||||
type AddPropDialogProps = {
|
||||
mode?: "create" | "edit"
|
||||
initialProp?: Prop
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSave?: (prop: Prop) => void
|
||||
}
|
||||
|
||||
export function AddPropDialog({
|
||||
mode = "create",
|
||||
initialProp,
|
||||
open: controlledOpen,
|
||||
onOpenChange: setControlledOpen,
|
||||
onSave,
|
||||
}: AddPropDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// 表单状态
|
||||
const [name, setName] = useState("")
|
||||
const [propType, setPropType] = useState("")
|
||||
const [rarity, setRarity] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [previewId, setPreviewId] = useState(
|
||||
"PRP" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
|
||||
// 当编辑模式且有道具数据时,初始化表单
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && initialProp) {
|
||||
setName(initialProp.name)
|
||||
setPropType(initialProp.type)
|
||||
setRarity(initialProp.rarity)
|
||||
setDescription(initialProp.description)
|
||||
setIsLimited(initialProp.type === "限定道具")
|
||||
setPreviewId(initialProp.id)
|
||||
}
|
||||
}, [mode, initialProp])
|
||||
|
||||
// 处理受控和非受控开关状态
|
||||
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("")
|
||||
setPropType("")
|
||||
setRarity("")
|
||||
setDescription("")
|
||||
setIsLimited(false)
|
||||
setPreviewId(
|
||||
"PRP" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!name || !propType || !rarity || !description) {
|
||||
alert("请填写所有必填字段!")
|
||||
return
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 调用保存回调
|
||||
if (onSave) {
|
||||
onSave(prop)
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
handleOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error(mode === "create" ? "创建道具失败:" : "更新道具失败:", error)
|
||||
alert(mode === "create" ? "创建道具失败,请重试!" : "更新道具失败,请重试!")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
{mode === "create" ? "填写道具信息以创建新的道具卡牌。创建后将生成唯一的卡牌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"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-right">
|
||||
道具类型 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={propType} onValueChange={setPropType} 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>
|
||||
</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">
|
||||
道具<EFBFBD><EFBFBD><EFBFBD>述 <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>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer">
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">点击或拖拽上传道具图片</p>
|
||||
<p className="text-xs text-gray-400 mt-1">支持 PNG, JPG, JPEG 格式,最大 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="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>
|
||||
)}
|
||||
|
||||
{mode === "create" && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="text-sm font-medium mb-2">预览信息</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
道具ID: <span className="font-medium text-pink-600">{previewId}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
状态: <span className="font-medium">未发布</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
146
qy-lty-admin/components/props/export-cards-dialog.tsx
Normal file
146
qy-lty-admin/components/props/export-cards-dialog.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { FileDown, Loader2 } from "lucide-react"
|
||||
|
||||
interface ExportCardsDialogProps {
|
||||
propId: string
|
||||
}
|
||||
|
||||
export function ExportCardsDialog({ propId }: ExportCardsDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [format, setFormat] = useState("csv")
|
||||
const [exportScope, setExportScope] = useState("all")
|
||||
const [includeHeaders, setIncludeHeaders] = useState(true)
|
||||
const [includeMetadata, setIncludeMetadata] = useState(true)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
// 模拟导出操作
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
setIsExporting(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="border-purple-200 hover:bg-purple-50 hover:text-purple-700">
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
导出卡牌ID
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
导出卡牌ID
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择导出格式并下载道具 <span className="font-medium">{propId}</span> 的卡牌ID。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="format">导出格式</Label>
|
||||
<Select value={format} onValueChange={setFormat}>
|
||||
<SelectTrigger id="format">
|
||||
<SelectValue placeholder="选择格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">CSV文件</SelectItem>
|
||||
<SelectItem value="excel">Excel文件</SelectItem>
|
||||
<SelectItem value="pdf">PDF文件</SelectItem>
|
||||
<SelectItem value="json">JSON文件</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>导出范围</Label>
|
||||
<RadioGroup value={exportScope} onValueChange={setExportScope} className="flex flex-col space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="all" />
|
||||
<Label htmlFor="all" className="font-normal">
|
||||
所有卡牌ID
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="unused" id="unused" />
|
||||
<Label htmlFor="unused" className="font-normal">
|
||||
未激活的卡牌ID
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="used" id="used" />
|
||||
<Label htmlFor="used" className="font-normal">
|
||||
已激活的卡牌ID
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<Label>导出选项</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="headers"
|
||||
checked={includeHeaders}
|
||||
onCheckedChange={(checked) => setIncludeHeaders(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="headers" className="text-sm font-normal">
|
||||
包含表头
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="metadata"
|
||||
checked={includeMetadata}
|
||||
onCheckedChange={(checked) => setIncludeMetadata(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="metadata" className="text-sm font-normal">
|
||||
包含元数据(创建时间、批次信息等)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isExporting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
导出中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
导出
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
109
qy-lty-admin/components/props/prop-detail-dialog.tsx
Normal file
109
qy-lty-admin/components/props/prop-detail-dialog.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Eye, Edit } from "lucide-react"
|
||||
|
||||
export type Prop = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
rarity: string
|
||||
description: string
|
||||
releaseDate: string
|
||||
status: string
|
||||
activatedCount: number
|
||||
image?: string
|
||||
}
|
||||
|
||||
type PropDetailDialogProps = {
|
||||
prop: Prop
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
export function PropDetailDialog({ prop, onEdit }: PropDetailDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600">
|
||||
<Eye className="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">
|
||||
道具详情
|
||||
</DialogTitle>
|
||||
<DialogDescription>查看道具的详细信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-24 w-24 rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
|
||||
<img
|
||||
src={prop.image || "/placeholder.svg?height=96&width=96"}
|
||||
alt={prop.name}
|
||||
className="object-cover h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">{prop.name}</h3>
|
||||
<p className="text-sm text-gray-500">ID: {prop.id}</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge className="bg-purple-500">{prop.type}</Badge>
|
||||
<Badge className="bg-blue-500">{prop.rarity}</Badge>
|
||||
<Badge className={prop.status === "已发布" ? "bg-green-500" : "bg-gray-500"}>{prop.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">道具描述</h4>
|
||||
<p className="text-sm">{prop.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">发布日期</h4>
|
||||
<p className="text-sm">{prop.releaseDate || "未发布"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">激活数量</h4>
|
||||
<p className="text-sm">{prop.activatedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
{prop.status !== "已发布" && onEdit && (
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑道具
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
110
qy-lty-admin/components/publish-confirmation-dialog.tsx
Normal file
110
qy-lty-admin/components/publish-confirmation-dialog.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { AlertCircle, Loader2, Send } from "lucide-react"
|
||||
|
||||
type PublishConfirmationDialogProps = {
|
||||
title: string
|
||||
description: string
|
||||
itemName: string
|
||||
onPublish: () => Promise<void>
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PublishConfirmationDialog({
|
||||
title,
|
||||
description,
|
||||
itemName,
|
||||
onPublish,
|
||||
trigger,
|
||||
}: PublishConfirmationDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isPublishing, setIsPublishing] = useState(false)
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true)
|
||||
try {
|
||||
await onPublish()
|
||||
setOpen(false)
|
||||
} catch (error) {
|
||||
console.error("发布失败:", error)
|
||||
// 失败处理已经在onPublish中处理,这里不需要额外处理
|
||||
} finally {
|
||||
setIsPublishing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-green-50 hover:text-green-600"
|
||||
title="发布歌曲"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-green-600 flex items-center">
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
您确定要发布 <span className="font-medium text-green-600">{itemName}</span> 吗?
|
||||
</p>
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-md">
|
||||
<p className="text-sm text-amber-700 font-medium">
|
||||
重要提示
|
||||
</p>
|
||||
<p className="text-sm text-amber-600 mt-1">
|
||||
一旦发布后,将不能再修改任何卡片属性,包括名称、描述、音频等信息。请确认所有信息正确无误。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isPublishing}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
发布中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
确认发布
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
77
qy-lty-admin/components/recent-activity.tsx
Normal file
77
qy-lty-admin/components/recent-activity.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
|
||||
export function RecentActivity() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center p-2 rounded-lg hover:bg-blue-50 transition-colors duration-200">
|
||||
<Avatar className="h-10 w-10 border-2 border-white shadow-sm">
|
||||
<AvatarImage src="/placeholder-user.jpg" alt="Avatar" />
|
||||
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-teal-500 text-white">管</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">管理员更新了服装卡牌</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-green-500 mr-1.5"></span>
|
||||
2分钟前
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center p-2 rounded-lg hover:bg-blue-50 transition-colors duration-200">
|
||||
<Avatar className="h-10 w-10 border-2 border-white shadow-sm">
|
||||
<AvatarImage src="/placeholder-user.jpg" alt="Avatar" />
|
||||
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-pink-500 text-white">系</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">系统完成了大模型微调</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500 mr-1.5"></span>
|
||||
45分钟前
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center p-2 rounded-lg hover:bg-blue-50 transition-colors duration-200">
|
||||
<Avatar className="h-10 w-10 border-2 border-white shadow-sm">
|
||||
<AvatarImage src="/placeholder-user.jpg" alt="Avatar" />
|
||||
<AvatarFallback className="bg-gradient-to-br from-yellow-500 to-orange-500 text-white">张</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">张三添加了新歌曲</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-indigo-500 mr-1.5"></span>
|
||||
3小时前
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center p-2 rounded-lg hover:bg-blue-50 transition-colors duration-200">
|
||||
<Avatar className="h-10 w-10 border-2 border-white shadow-sm">
|
||||
<AvatarImage src="/placeholder-user.jpg" alt="Avatar" />
|
||||
<AvatarFallback className="bg-gradient-to-br from-red-500 to-pink-500 text-white">李</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">李四更新了好感度系统参数</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-purple-500 mr-1.5"></span>
|
||||
5小时前
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center p-2 rounded-lg hover:bg-blue-50 transition-colors duration-200">
|
||||
<Avatar className="h-10 w-10 border-2 border-white shadow-sm">
|
||||
<AvatarImage src="/placeholder-user.jpg" alt="Avatar" />
|
||||
<AvatarFallback className="bg-gradient-to-br from-green-500 to-teal-500 text-white">王</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">王五添加了新的家居装饰</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-gray-500 mr-1.5"></span>
|
||||
昨天
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
291
qy-lty-admin/components/sidebar.tsx
Normal file
291
qy-lty-admin/components/sidebar.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
"use client"
|
||||
|
||||
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 {
|
||||
Brain,
|
||||
Music,
|
||||
Shirt,
|
||||
Gift,
|
||||
Home,
|
||||
Utensils,
|
||||
User,
|
||||
Settings,
|
||||
BarChart,
|
||||
LogOut,
|
||||
Lock,
|
||||
Sparkles,
|
||||
Heart,
|
||||
Footprints,
|
||||
Trophy,
|
||||
} from "lucide-react"
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// 调用退出登录API
|
||||
await logout()
|
||||
|
||||
// 退出后重定向到登录页面
|
||||
router.push("/login")
|
||||
} catch (error) {
|
||||
console.error("退出登录失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex h-16 items-center px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Button
|
||||
variant={pathname === "/" ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start",
|
||||
pathname === "/"
|
||||
? "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="/">
|
||||
<BarChart 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">AI 管理</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={pathname === "/ai-model" ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start",
|
||||
pathname === "/ai-model"
|
||||
? "bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700"
|
||||
: "hover:bg-gray-100",
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/ai-model">
|
||||
<Brain 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 === "/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>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
qy-lty-admin/components/songs/add-print-batch-dialog.tsx
Normal file
139
qy-lty-admin/components/songs/add-print-batch-dialog.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Plus, Loader2 } from "lucide-react"
|
||||
import { createSongBatch } from "@/lib/api/songs"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
interface AddPrintBatchDialogProps {
|
||||
songId: string
|
||||
isPublished: boolean
|
||||
onBatchCreated?: () => Promise<void>
|
||||
}
|
||||
|
||||
export function AddPrintBatchDialog({ songId, isPublished, onBatchCreated }: AddPrintBatchDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [quantity, setQuantity] = useState(1000)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await createSongBatch({
|
||||
template: Number(songId),
|
||||
quantity: quantity
|
||||
})
|
||||
|
||||
toast({
|
||||
title: "批次创建成功",
|
||||
description: `已成功创建包含 ${quantity} 个卡片的新批次`,
|
||||
})
|
||||
|
||||
// 关闭对话框
|
||||
setOpen(false)
|
||||
|
||||
// 调用回调函数刷新批次列表
|
||||
if (onBatchCreated) {
|
||||
await onBatchCreated()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("创建批次失败:", error)
|
||||
toast({
|
||||
title: "创建批次失败",
|
||||
description: error instanceof Error ? error.message : "无法创建批次,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 transition-all duration-300 shadow-md hover:shadow-lg">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加印刷批次
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
添加新印刷批次
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
为歌曲 <span className="font-medium">{songId}</span> 添加新的卡牌印刷批次。系统将自动生成唯一的卡牌ID。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity" className="text-right">
|
||||
印刷数量
|
||||
</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Number.parseInt(e.target.value))}
|
||||
className="border-gray-300 focus-visible:ring-purple-500"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">将生成 {quantity} 个新的卡牌ID</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-right">预览</Label>
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-700">
|
||||
批次ID:{" "}
|
||||
<span className="font-mono">
|
||||
系统自动生成
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
起始ID: <span className="font-mono">系统自动生成</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
结束ID: <span className="font-mono">系统自动生成</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">实际ID将在创建批次时生成</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
"创建批次"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
337
qy-lty-admin/components/songs/add-song-dialog.tsx
Normal file
337
qy-lty-admin/components/songs/add-song-dialog.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
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 { Switch } from "@/components/ui/switch"
|
||||
import type { Song } from "./song-detail-dialog"
|
||||
import { uploadSongFile, createSong, updateSong } from "@/lib/api/songs"
|
||||
|
||||
type AddSongDialogProps = {
|
||||
mode?: "create" | "edit"
|
||||
initialSong?: Song
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSave?: (song: Song) => void
|
||||
}
|
||||
|
||||
export function AddSongDialog({
|
||||
mode = "create",
|
||||
initialSong,
|
||||
open: controlledOpen,
|
||||
onOpenChange: setControlledOpen,
|
||||
onSave,
|
||||
}: AddSongDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// 表单状态
|
||||
const [name, setName] = useState("")
|
||||
const [songType, setSongType] = useState("")
|
||||
const [composer, setComposer] = useState("")
|
||||
const [lyricist, setLyricist] = useState("")
|
||||
const [duration, setDuration] = useState("")
|
||||
const [bpm, setBpm] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [isOriginal, setIsOriginal] = useState(true)
|
||||
const [previewId, setPreviewId] = useState(
|
||||
"SNG" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null)
|
||||
const [audioUrl, setAudioUrl] = useState<string>("")
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string>("")
|
||||
|
||||
const audioInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 当编辑模式且有歌曲数据时,初始化表单
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && initialSong) {
|
||||
setName(initialSong.name)
|
||||
setComposer(initialSong.composer)
|
||||
setLyricist(initialSong.lyricist)
|
||||
setDuration(initialSong.duration)
|
||||
setIsOriginal(true) // 假设所有编辑的歌曲都是原创
|
||||
setPreviewId(initialSong.id)
|
||||
|
||||
// 初始化歌曲类型
|
||||
if (initialSong.genre) {
|
||||
setSongType(initialSong.genre)
|
||||
}
|
||||
|
||||
// 初始化BPM
|
||||
if (initialSong.bpm) {
|
||||
setBpm(String(initialSong.bpm))
|
||||
}
|
||||
|
||||
// 初始化描述
|
||||
if (initialSong.description) {
|
||||
setDescription(initialSong.description)
|
||||
}
|
||||
|
||||
// 初始化音频URL
|
||||
if (initialSong.audioUrl) {
|
||||
setAudioUrl(initialSong.audioUrl)
|
||||
}
|
||||
}
|
||||
}, [mode, initialSong])
|
||||
|
||||
// 处理受控和非受控开关状态
|
||||
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("")
|
||||
setSongType("")
|
||||
setComposer("")
|
||||
setLyricist("")
|
||||
setDuration("")
|
||||
setBpm("")
|
||||
setDescription("")
|
||||
setIsOriginal(true)
|
||||
setPreviewId(
|
||||
"SNG" +
|
||||
Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 歌曲文件选择和上传
|
||||
const handleAudioFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setAudioFile(file)
|
||||
setIsUploading(true)
|
||||
setUploadError("")
|
||||
try {
|
||||
const res = await uploadSongFile({ file, isPermanent: true, filename: file.name })
|
||||
setAudioUrl(res.url)
|
||||
} catch (err: any) {
|
||||
setUploadError(err.message || "上传失败")
|
||||
setAudioUrl("")
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!name || !composer || !lyricist || !duration) {
|
||||
alert("请填写所有必填字段!")
|
||||
return
|
||||
}
|
||||
|
||||
// 创建模式下必须上传音频
|
||||
if (mode === "create" && !audioUrl) {
|
||||
alert("请上传歌曲文件!")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
// 构造基本的请求数据
|
||||
const songAttributes: any = {
|
||||
genre: songType || undefined,
|
||||
duration,
|
||||
composer,
|
||||
lyricist,
|
||||
arrangement: isOriginal ? undefined : "",
|
||||
lyrics: description || undefined,
|
||||
}
|
||||
|
||||
// 只有在有音频URL时才添加
|
||||
if (audioUrl) {
|
||||
songAttributes.audio_file = encodeURI(audioUrl)
|
||||
}
|
||||
|
||||
// 只有在有BPM时才添加
|
||||
if (bpm) {
|
||||
songAttributes.bpm = Number(bpm)
|
||||
}
|
||||
|
||||
if (mode === "create") {
|
||||
// 创建模式
|
||||
const payload = {
|
||||
name,
|
||||
category: "song",
|
||||
card_type: "regular",
|
||||
rarity: "epic",
|
||||
price: 39.99,
|
||||
status: "draft",
|
||||
description: description || undefined,
|
||||
song_attributes: songAttributes
|
||||
}
|
||||
const created = await createSong(payload)
|
||||
if (onSave) onSave(created)
|
||||
} else if (mode === "edit" && initialSong) {
|
||||
// 编辑模式
|
||||
const payload = {
|
||||
name,
|
||||
category: "song",
|
||||
song_attributes: songAttributes
|
||||
}
|
||||
const updated = await updateSong(initialSong.id, payload)
|
||||
if (onSave) onSave(updated)
|
||||
}
|
||||
|
||||
handleOpenChange(false)
|
||||
} catch (error: any) {
|
||||
alert(error.message || (mode === "create" ? "创建歌曲失败" : "更新歌曲失败"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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-[520px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
{mode === "create" ? "添加新歌曲" : "编辑歌曲"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs mt-1 mb-2">{mode === "create" ? "填写歌曲信息以创建新的歌曲。" : "修改歌曲信息。"}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name" className="text-xs">歌曲名称 <span className="text-red-500">*</span></Label>
|
||||
<Input id="name" placeholder="输入歌曲名称" className="h-8 text-xs border-gray-300 focus-visible:ring-pink-500" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="type" className="text-xs">歌曲类型</Label>
|
||||
<Select value={songType} onValueChange={setSongType}>
|
||||
<SelectTrigger className="h-8 text-xs border-gray-300 focus:ring-pink-500">
|
||||
<SelectValue placeholder="选择歌曲类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pop">流行</SelectItem>
|
||||
<SelectItem value="rock">摇滚</SelectItem>
|
||||
<SelectItem value="electronic">电子</SelectItem>
|
||||
<SelectItem value="folk">民谣</SelectItem>
|
||||
<SelectItem value="classical">古典</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="composer" className="text-xs">作曲 <span className="text-red-500">*</span></Label>
|
||||
<Input id="composer" placeholder="输入作曲者" className="h-8 text-xs border-gray-300 focus-visible:ring-pink-500" value={composer} onChange={(e) => setComposer(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lyricist" className="text-xs">作词 <span className="text-red-500">*</span></Label>
|
||||
<Input id="lyricist" placeholder="输入作词者" className="h-8 text-xs border-gray-300 focus-visible:ring-pink-500" value={lyricist} onChange={(e) => setLyricist(e.target.value)} required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="duration" className="text-xs">时长 <span className="text-red-500">*</span></Label>
|
||||
<Input id="duration" placeholder="格式: MM:SS" className="h-8 text-xs border-gray-300 focus-visible:ring-pink-500" value={duration} onChange={(e) => setDuration(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bpm" className="text-xs">BPM</Label>
|
||||
<Input id="bpm" type="number" placeholder="输入BPM" className="h-8 text-xs border-gray-300 focus-visible:ring-pink-500" value={bpm} onChange={(e) => setBpm(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">歌曲文件 {mode === "create" && <span className="text-red-500">*</span>}</Label>
|
||||
<div
|
||||
className="relative 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]"
|
||||
onClick={() => !isUploading && !isSubmitting && audioInputRef.current?.click()}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<Music className="h-5 w-5 text-gray-400 mb-1" />
|
||||
<span className="text-xs text-gray-500">{audioFile ? audioFile.name : "上传"}</span>
|
||||
<input
|
||||
ref={audioInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={handleAudioFileChange}
|
||||
disabled={isUploading || isSubmitting}
|
||||
/>
|
||||
{isUploading && <span className="text-xs text-pink-500 mt-1">上传中...</span>}
|
||||
{audioUrl && !isUploading && <span className="text-xs text-green-600 mt-1">已上传</span>}
|
||||
{uploadError && <span className="text-xs text-red-500 mt-1">{uploadError}</span>}
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{/* 可折叠描述和预览信息 */}
|
||||
<div className="mt-1">
|
||||
<details className="mb-1">
|
||||
<summary className="cursor-pointer text-xs text-gray-600 select-none">歌曲描述</summary>
|
||||
<Textarea id="description" placeholder="输入歌曲描述" className="min-h-[60px] text-xs border-gray-300 focus-visible:ring-pink-500 mt-1" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</details>
|
||||
{mode === "create" && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs text-gray-600 select-none">预览信息</summary>
|
||||
<div className="p-2 bg-gray-50 rounded mt-1">
|
||||
<p className="text-xs text-gray-600">歌曲ID: <span className="font-medium text-pink-600">{previewId}</span></p>
|
||||
<p className="text-xs text-gray-600 mt-1">状态: <span className="font-medium">未发布</span></p>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-1 pb-2">
|
||||
<Switch id="original" checked={isOriginal} onCheckedChange={setIsOriginal} />
|
||||
<Label htmlFor="original" className="cursor-pointer text-xs">这是原创歌曲</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting} className="h-8 px-4 text-xs">取消</Button>
|
||||
<Button className="h-8 px-4 text-xs 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>
|
||||
)
|
||||
}
|
||||
172
qy-lty-admin/components/songs/export-batch-dialog.tsx
Normal file
172
qy-lty-admin/components/songs/export-batch-dialog.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Download } from "lucide-react"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { exportBatchCards } from "@/lib/api/card"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { isAuthenticated } from "@/lib/api/auth"
|
||||
|
||||
interface ExportBatchDialogProps {
|
||||
batchId: number
|
||||
}
|
||||
|
||||
export function ExportBatchDialog({ batchId }: ExportBatchDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [format, setFormat] = useState<'xlsx' | 'csv'>('xlsx')
|
||||
const { toast } = useToast()
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
// 检查登录状态
|
||||
if (!isAuthenticated()) {
|
||||
toast({
|
||||
title: "未登录",
|
||||
description: "请先登录后再进行操作",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
const data = await exportBatchCards(batchId, format)
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a')
|
||||
const fileExtension = format
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const fileName = `batch-${batchId}-${timestamp}.${fileExtension}`
|
||||
|
||||
if (format === 'xlsx') {
|
||||
// 处理二进制数据 (Excel)
|
||||
const blob = new Blob([data as Blob], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
document.body.removeChild(link)
|
||||
} else {
|
||||
// 处理文本数据 (CSV)
|
||||
const blob = new Blob([data as string], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "导出成功",
|
||||
description: `批次 ${batchId} 已成功导出为 ${format.toUpperCase()} 格式`,
|
||||
})
|
||||
|
||||
setOpen(false)
|
||||
} catch (error) {
|
||||
console.error("导出失败:", error)
|
||||
|
||||
// 处理不同类型的错误
|
||||
let errorMessage = "无法导出批次,请稍后重试"
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message
|
||||
} else if (typeof error === 'object' && error !== null && 'response' in error) {
|
||||
// Axios 错误
|
||||
const axiosError = error as any
|
||||
if (axiosError.response?.status === 401) {
|
||||
errorMessage = "认证失败,请重新登录"
|
||||
} else if (axiosError.response?.data?.message) {
|
||||
errorMessage = axiosError.response.data.message
|
||||
} else if (axiosError.response?.statusText) {
|
||||
errorMessage = `请求失败: ${axiosError.response.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "导出失败",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 hover:bg-blue-50 hover:text-blue-600"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
导出
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导出批次数据</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择导出格式并下载批次 {batchId} 的卡片数据
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) => setFormat(value as 'xlsx' | 'csv')}
|
||||
className="flex flex-col space-y-3"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="xlsx" id="xlsx" />
|
||||
<Label htmlFor="xlsx" className="font-medium">Excel (XLSX)</Label>
|
||||
<span className="text-xs text-gray-500 ml-1">- 包含格式的电子表格</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="csv" id="csv" />
|
||||
<Label htmlFor="csv" className="font-medium">CSV</Label>
|
||||
<span className="text-xs text-gray-500 ml-1">- 通用文本格式,兼容所有软件</span>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
className="bg-gradient-to-r from-blue-500 to-blue-600"
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
导出中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出 {format.toUpperCase()}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
79
qy-lty-admin/components/songs/export-cards-dialog.tsx
Normal file
79
qy-lty-admin/components/songs/export-cards-dialog.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FileDown } from "lucide-react"
|
||||
|
||||
interface ExportCardsDialogProps {
|
||||
songId: string
|
||||
}
|
||||
|
||||
export function ExportCardsDialog({ songId }: ExportCardsDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [format, setFormat] = useState("csv")
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="border-purple-200 hover:bg-purple-50 hover:text-purple-700">
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
导出卡牌ID
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
导出卡牌ID
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择导出格式并下载歌曲 <span className="font-medium">{songId}</span> 的所有卡牌ID。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="format">导出格式</Label>
|
||||
<Select value={format} onValueChange={setFormat}>
|
||||
<SelectTrigger id="format">
|
||||
<SelectValue placeholder="选择格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">CSV文件</SelectItem>
|
||||
<SelectItem value="excel">Excel文件</SelectItem>
|
||||
<SelectItem value="pdf">PDF文件</SelectItem>
|
||||
<SelectItem value="json">JSON文件</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
||||
onClick={() => {
|
||||
// 模拟导出操作
|
||||
setTimeout(() => {
|
||||
setOpen(false)
|
||||
}, 1000)
|
||||
}}
|
||||
>
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
导出
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
299
qy-lty-admin/components/songs/song-detail-dialog.tsx
Normal file
299
qy-lty-admin/components/songs/song-detail-dialog.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Eye, Edit, Play, Pause, Volume2, VolumeX } from "lucide-react"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
|
||||
export type Song = {
|
||||
id: string
|
||||
name: string
|
||||
composer: string
|
||||
lyricist: string
|
||||
duration: string
|
||||
releaseDate?: string
|
||||
status: string
|
||||
image?: string
|
||||
audioUrl?: string
|
||||
description?: string
|
||||
rarity?: string
|
||||
cardType?: string
|
||||
genre?: string
|
||||
lyrics?: string
|
||||
bpm?: string | number
|
||||
}
|
||||
|
||||
type SongDetailDialogProps = {
|
||||
song: Song
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
export function SongDetailDialog({ song, onEdit }: SongDetailDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [volume, setVolume] = useState(80)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
// 初始化音频播放器
|
||||
useEffect(() => {
|
||||
if (open && !audioRef.current && song.audioUrl) {
|
||||
audioRef.current = new Audio(song.audioUrl)
|
||||
|
||||
const audio = audioRef.current
|
||||
|
||||
// 设置事件监听器
|
||||
audio.addEventListener('canplay', () => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
setIsPlaying(false)
|
||||
})
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
setIsPlaying(false)
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
||||
// 设置音量
|
||||
audio.volume = volume / 100
|
||||
audio.muted = isMuted
|
||||
}
|
||||
|
||||
// 关闭对话框时清理
|
||||
if (!open && audioRef.current) {
|
||||
const audio = audioRef.current
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
audioRef.current = null
|
||||
setIsPlaying(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
const audio = audioRef.current
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
|
||||
audio.removeEventListener('canplay', () => {})
|
||||
audio.removeEventListener('ended', () => {})
|
||||
audio.removeEventListener('error', () => {})
|
||||
}
|
||||
}
|
||||
}, [open, song.audioUrl, volume, isMuted])
|
||||
|
||||
// 监听音量变化
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume / 100
|
||||
}
|
||||
}, [volume])
|
||||
|
||||
// 监听静音状态
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = isMuted
|
||||
}
|
||||
}, [isMuted])
|
||||
|
||||
const togglePlay = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
if (!song.audioUrl) return
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
setIsLoading(true)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true)
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('播放失败:', error)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换静音状态
|
||||
const toggleMute = () => {
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
|
||||
// 处理音量变化
|
||||
const handleVolumeChange = (values: number[]) => {
|
||||
const newVolume = values[0]
|
||||
setVolume(newVolume)
|
||||
|
||||
// 如果音量从0调高,则取消静音
|
||||
if (newVolume > 0 && isMuted) {
|
||||
setIsMuted(false)
|
||||
}
|
||||
// 如果音量调到0,则设置为静音
|
||||
else if (newVolume === 0 && !isMuted) {
|
||||
setIsMuted(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="hover:bg-blue-50 hover:text-blue-600">
|
||||
<Eye className="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">
|
||||
歌曲详情
|
||||
</DialogTitle>
|
||||
<DialogDescription>查看歌曲的详细信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative h-24 w-24 rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center group">
|
||||
<img
|
||||
src={song.image || "/placeholder.svg?height=96&width=96"}
|
||||
alt={song.name}
|
||||
className="object-cover h-full w-full"
|
||||
/>
|
||||
{song.audioUrl && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="h-10 w-10 flex items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
</div>
|
||||
) : isPlaying ? (
|
||||
<Pause className="h-10 w-10 text-white" />
|
||||
) : (
|
||||
<Play className="h-10 w-10 text-white" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">{song.name}</h3>
|
||||
<p className="text-sm text-gray-500">ID: {song.id}</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge className={song.status === "已发布" ? "bg-green-500" : "bg-gray-500"}>{song.status}</Badge>
|
||||
{song.rarity && <Badge className="bg-purple-500">{song.rarity}</Badge>}
|
||||
{song.cardType && <Badge className="bg-blue-500">{song.cardType}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音频控制器 - 仅当歌曲有音频URL且正在播放时显示 */}
|
||||
{song.audioUrl && isPlaying && (
|
||||
<div className="flex items-center space-x-4 bg-pink-50 p-3 rounded-lg">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full hover:bg-pink-100"
|
||||
onClick={toggleMute}
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-3.5 w-3.5 text-pink-500" /> : <Volume2 className="h-3.5 w-3.5 text-pink-500" />}
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<Slider
|
||||
value={[volume]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">作曲</h4>
|
||||
<p className="text-sm">{song.composer}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">作词</h4>
|
||||
<p className="text-sm">{song.lyricist}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">时长</h4>
|
||||
<p className="text-sm">{song.duration}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">发布日期</h4>
|
||||
<p className="text-sm">{song.releaseDate || "未发布"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{song.genre && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">风格</h4>
|
||||
<p className="text-sm">{song.genre}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{song.description && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">描述</h4>
|
||||
<p className="text-sm">{song.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{song.lyrics && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-1">歌词</h4>
|
||||
<div className="text-sm max-h-40 overflow-y-auto p-2 bg-gray-50 rounded">
|
||||
<pre className="whitespace-pre-wrap font-sans">{song.lyrics}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
{song.status !== "已发布" && onEdit && (
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
编辑歌曲
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
38
qy-lty-admin/components/stat-card.tsx
Normal file
38
qy-lty-admin/components/stat-card.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import type React from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ArrowDown, ArrowUp } from "lucide-react"
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string
|
||||
change: string
|
||||
icon: React.ReactNode
|
||||
trend: "up" | "down" | "neutral"
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, change, icon, trend }: StatCardProps) {
|
||||
return (
|
||||
<Card className="border-none shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden relative group">
|
||||
{/* 修改这里:将overlay的z-index设置为-1,确保它不会覆盖文字内容 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-gray-100 to-gray-50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 -z-10"></div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<div className="p-1.5 rounded-full bg-gray-100 group-hover:scale-110 transition-transform duration-300">
|
||||
{icon}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<div className="flex items-center mt-1">
|
||||
{trend === "up" && <ArrowUp className="h-4 w-4 text-green-500 mr-1" />}
|
||||
{trend === "down" && <ArrowDown className="h-4 w-4 text-red-500 mr-1" />}
|
||||
<p
|
||||
className={`text-xs ${trend === "up" ? "text-green-500" : trend === "down" ? "text-red-500" : "text-muted-foreground"}`}
|
||||
>
|
||||
{change}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
11
qy-lty-admin/components/theme-provider.tsx
Normal file
11
qy-lty-admin/components/theme-provider.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
type ThemeProviderProps,
|
||||
} from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
58
qy-lty-admin/components/ui/accordion.tsx
Normal file
58
qy-lty-admin/components/ui/accordion.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
141
qy-lty-admin/components/ui/alert-dialog.tsx
Normal file
141
qy-lty-admin/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
59
qy-lty-admin/components/ui/alert.tsx
Normal file
59
qy-lty-admin/components/ui/alert.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
7
qy-lty-admin/components/ui/aspect-ratio.tsx
Normal file
7
qy-lty-admin/components/ui/aspect-ratio.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
||||
50
qy-lty-admin/components/ui/avatar.tsx
Normal file
50
qy-lty-admin/components/ui/avatar.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
36
qy-lty-admin/components/ui/badge.tsx
Normal file
36
qy-lty-admin/components/ui/badge.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
115
qy-lty-admin/components/ui/breadcrumb.tsx
Normal file
115
qy-lty-admin/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||
Breadcrumb.displayName = "Breadcrumb"
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbList.displayName = "BreadcrumbList"
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
56
qy-lty-admin/components/ui/button.tsx
Normal file
56
qy-lty-admin/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user