Files
sundynix-plant-admin/src/pages/plant/conf/Level.tsx
T
2026-02-14 14:32:16 +08:00

456 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { Plus, Edit, Crown, Sprout, X, Search, Trophy, Star, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { getLevelConfList, addLevelConf, updateLevelConf, type LevelConf } from '@/api/config'
import { cn } from '@/lib/utils'
interface LevelFormData {
id?: string
level: number
title: string
minSunlight: number
perks: string
}
const defaultFormData: LevelFormData = {
level: 1,
title: '',
minSunlight: 0,
perks: '',
}
export default function LevelConfigPage() {
const [levels, setLevels] = useState<LevelConf[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [formData, setFormData] = useState<LevelFormData>(defaultFormData)
const [isEdit, setIsEdit] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
// 获取列表
const fetchLevels = async () => {
setLoading(true)
try {
const res = await getLevelConfList()
let list: LevelConf[] = []
if (res && res.data && Array.isArray(res.data.list)) {
list = res.data.list
}
// Sort by level
list.sort((a, b) => a.level - b.level)
setLevels(list)
} catch (error) {
console.error('获取等级配置失败:', error)
setLevels([])
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchLevels()
}, [])
// 打开新增对话框
const handleAdd = () => {
setIsEdit(false)
// Auto increment level suggestion
const maxLevel = levels.length > 0 ? Math.max(...levels.map(l => l.level)) : 0
setFormData({ ...defaultFormData, level: maxLevel + 1 })
setDialogOpen(true)
}
// 打开编辑对话框
const handleEdit = (level: LevelConf) => {
setIsEdit(true)
setFormData({
id: level.id,
level: level.level,
title: level.title,
minSunlight: level.minSunlight,
perks: level.perks,
})
setDialogOpen(true)
}
// 提交表单
const handleSubmit = async () => {
if (!formData.title) return
setSubmitting(true)
try {
if (isEdit && formData.id) {
await updateLevelConf({ ...formData, id: formData.id })
} else {
await addLevelConf(formData)
}
setDialogOpen(false)
fetchLevels()
} catch (error) {
console.error('保存等级配置失败:', error)
} finally {
setSubmitting(false)
}
}
const filteredLevels = levels.filter(l =>
l.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
l.perks?.toLowerCase().includes(searchTerm.toLowerCase())
)
// Calculate stats
const maxSunlight = Math.max(...levels.map(l => l.minSunlight), 0)
const totalPerks = levels.reduce((acc, curr) => acc + (curr.perks ? 1 : 0), 0)
return (
<div className="space-y-6 animate-fadeIn">
{/* Header & Stats */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="border-l-4 border-l-primary shadow-sm hover:shadow-md transition-all">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
<Trophy className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{levels.length}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500 shadow-sm hover:shadow-md transition-all">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
<Sprout className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{maxSunlight.toLocaleString()}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-yellow-500 shadow-sm hover:shadow-md transition-all">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
<Star className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{levels.length > 0 ? Math.round((totalPerks / levels.length) * 100) : 0}%</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 shadow-sm hover:shadow-md transition-all group cursor-pointer bg-gradient-to-br from-background to-purple-50/50 hover:to-purple-100/50" onClick={handleAdd}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-purple-600"></CardTitle>
<div className="bg-purple-100 p-1 rounded-full group-hover:scale-110 transition-transform">
<Plus className="h-4 w-4 text-purple-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-purple-700">New</div>
<p className="text-xs text-purple-600/80 mt-1"></p>
</CardContent>
</Card>
</div>
{/* Main Content */}
<Card className="border-border/60 shadow-sm hover:shadow transition-shadow duration-300">
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
<div className="space-y-1">
<CardTitle className="text-xl flex items-center gap-2">
<Badge variant="secondary" className="font-normal text-xs">{levels.length}</Badge>
</CardTitle>
<CardDescription>
</CardDescription>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
<div className="relative w-full sm:w-auto">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索称号或权益..."
className="pl-9 w-full sm:w-[240px] h-10 bg-muted/30 focus:bg-background transition-colors"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button onClick={handleAdd} className="h-10 gap-2 shadow-sm bg-primary hover:bg-primary/90 transition-all active:scale-95">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex flex-col items-center justify-center py-16 space-y-4 text-muted-foreground bg-muted/10 rounded-lg border border-dashed border-border/60">
<div className="relative">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<div className="absolute inset-0 flex items-center justify-center">
<Crown className="h-4 w-4 text-primary opacity-50" />
</div>
</div>
<p className="text-sm font-medium animate-pulse">...</p>
</div>
) : (
<div className="rounded-lg border border-border/50 overflow-hidden bg-background">
<Table>
<TableHeader className="bg-muted/50 backdrop-blur-sm sticky top-0">
<TableRow className="hover:bg-transparent border-b-border/60">
<TableHead className="w-[100px] font-semibold pl-6"></TableHead>
<TableHead className="font-semibold"> & </TableHead>
<TableHead className="font-semibold"></TableHead>
<TableHead className="font-semibold"></TableHead>
<TableHead className="w-[100px] text-right font-semibold pr-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLevels.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-64 text-center">
<div className="flex flex-col items-center justify-center text-muted-foreground gap-3">
<div className="bg-muted/30 p-4 rounded-full">
<Search className="h-8 w-8 opacity-40" />
</div>
<div>
<p className="font-medium text-foreground"></p>
<p className="text-sm text-muted-foreground mt-1"></p>
</div>
{searchTerm && (
<Button variant="outline" size="sm" onClick={() => setSearchTerm('')} className="mt-2">
</Button>
)}
</div>
</TableCell>
</TableRow>
) : (
filteredLevels.map((item, index) => (
<TableRow
key={item.id}
className={cn(
"group transition-all duration-200 hover:bg-muted/30 border-b-border/40",
index % 2 === 1 && "bg-muted/5"
)}
>
<TableCell className="font-medium pl-6">
<div className="flex items-center gap-2">
<div className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg font-bold shadow-sm transition-transform group-hover:scale-110",
item.level <= 3 ? "bg-primary/10 text-primary border border-primary/20" :
item.level <= 6 ? "bg-purple-500/10 text-purple-600 border border-purple-500/20" :
"bg-orange-500/10 text-orange-600 border border-orange-500/20"
)}>
{item.level}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="bg-background p-1.5 rounded-full shadow-sm border border-border/50">
<Crown className={cn("h-4 w-4",
item.level <= 3 ? "text-primary/70" :
item.level <= 6 ? "text-purple-500" :
"text-orange-500"
)} />
</div>
<span className="font-medium text-foreground/90">{item.title}</span>
{item.level === levels.length && levels.length > 5 && (
<Badge variant="secondary" className="bg-gradient-to-r from-orange-500/10 to-red-500/10 text-orange-600 border-none text-[10px] px-1.5 py-0 h-5">MAX</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
<Sprout className="h-4 w-4 text-green-500/70" />
<span className="font-mono text-sm tracking-wide">{item.minSunlight.toLocaleString()}</span>
</div>
</TableCell>
<TableCell className="max-w-[300px]">
{item.perks ? (
<div className="flex items-start gap-2 group/perk">
<Sparkles className="h-3.5 w-3.5 mt-0.5 text-purple-500/70 shrink-0 group-hover/perk:text-purple-500 transition-colors" />
<p className="text-sm text-muted-foreground line-clamp-2 group-hover:text-foreground/80 transition-colors cursor-help" title={item.perks}>
{item.perks}
</p>
</div>
) : (
<span className="text-muted-foreground/30 text-sm italic flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-muted-foreground/20 rounded-full"></span>
</span>
)}
</TableCell>
<TableCell className="text-right pr-6">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground/60 hover:text-primary hover:bg-primary/10 transition-all duration-200"
onClick={() => handleEdit(item)}
>
<Edit className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
<div className={cn("p-2 rounded-lg", isEdit ? "bg-primary/10 text-primary" : "bg-purple-500/10 text-purple-600")}>
{isEdit ? <Edit className="h-5 w-5" /> : <Plus className="h-5 w-5" />}
</div>
{isEdit ? '编辑等级' : '新增等级'}
</DialogTitle>
<DialogDescription className="pt-2">
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-6">
<div className="grid grid-cols-2 gap-5">
<div className="grid gap-2">
<Label htmlFor="level" className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">
</Label>
<div className="relative group">
<Input
id="level"
type="number"
min={1}
value={formData.level}
onChange={(e) => setFormData({ ...formData, level: parseInt(e.target.value) || 0 })}
className="pr-8 font-mono bg-muted/30 focus:bg-background h-10 transition-all"
/>
{formData.level > 0 && (
<button
className="absolute right-2 top-3 text-muted-foreground/30 hover:text-destructive transition-colors"
onClick={() => setFormData({ ...formData, level: 0 })}
type="button"
tabIndex={-1}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="minSunlight" className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">
</Label>
<div className="relative group">
<Input
id="minSunlight"
type="number"
min={0}
value={formData.minSunlight}
onChange={(e) => setFormData({ ...formData, minSunlight: parseInt(e.target.value) || 0 })}
className="pr-8 font-mono bg-muted/30 focus:bg-background h-10 transition-all"
/>
{formData.minSunlight > 0 && (
<button
className="absolute right-2 top-3 text-muted-foreground/30 hover:text-destructive transition-colors"
onClick={() => setFormData({ ...formData, minSunlight: 0 })}
type="button"
tabIndex={-1}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="title" className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">
</Label>
<div className="relative group">
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="例如:萌芽园丁"
className="pr-8 bg-muted/30 focus:bg-background h-10 transition-all"
/>
{formData.title && (
<button
className="absolute right-2 top-3 text-muted-foreground/30 hover:text-destructive transition-colors"
onClick={() => setFormData({ ...formData, title: '' })}
type="button"
tabIndex={-1}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="perks" className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">
</Label>
<span className="text-[10px] text-muted-foreground"></span>
</div>
<div className="relative group">
<Textarea
id="perks"
value={formData.perks}
onChange={(e) => setFormData({ ...formData, perks: e.target.value })}
placeholder="例如:解锁每日签到双倍奖励、专属头像框"
className="h-28 pr-8 resize-none bg-muted/30 focus:bg-background transition-all leading-relaxed"
/>
{formData.perks && (
<button
className="absolute right-2 top-3 text-muted-foreground/30 hover:text-destructive transition-colors"
onClick={() => setFormData({ ...formData, perks: '' })}
type="button"
tabIndex={-1}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting} className="h-10 px-6">
</Button>
<Button onClick={handleSubmit} disabled={submitting} className="h-10 px-6 min-w-[80px]">
{submitting ? <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}