first commit

This commit is contained in:
zyc 2026-03-17 13:17:02 +08:00
commit 0c610c1e49
374 changed files with 61104 additions and 0 deletions

52
.gitignore vendored Normal file
View 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

View 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

View 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)

View 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
View 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
View 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
View 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

View 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>
)
}

View 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>
)
}

File diff suppressed because it is too large Load Diff

View 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">16158cm</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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View 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>
)
}

View 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>
}

View 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>
)
}

View 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>
)
}

View 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;
}
}

View 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>
)
}

View 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>
)
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View 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>
)
}

View 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>
)
}

View 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>
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View 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
View 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>
)
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View 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>
)
}

View 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>
}

View 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>
)
}

View 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>
)
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View 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>
)
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View 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;
}
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View 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>
)
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View 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>
)
}

View 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"
}

View File

@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
JPEGPNGGIF 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>
MP4GIFLottie 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>
MP3WAVOGG 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
}

View 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 }

View 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,
}

View 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 }

View File

@ -0,0 +1,7 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View 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 }

View 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 }

View 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,
}

View 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