feat: 徽章管理页面

This commit is contained in:
Blizzard
2026-02-12 15:39:42 +08:00
parent d948a39ad5
commit 5156255c77
13 changed files with 906 additions and 205 deletions
+65
View File
@@ -0,0 +1,65 @@
import {get, post} from '@/lib/request'
import type {SystemOss} from './system'
// ==================== 徽章配置 ====================
export interface Badge {
id: string
name: string
description?: string
iconId?: string
icon?: SystemOss
dimension: string
groupId: string
tier: number
targetAction: string
threshold: number
comparator: string
rewardSunlight: number
sort: number
isHidden?: boolean
createdAt?: string
updatedAt?: string
createdAtStr?: string
}
export interface BadgeGroup {
groupId: string
groupLabel: string
badges: Badge[]
}
export interface BadgeDimension {
dimension: string
label: string
groups: BadgeGroup[]
}
// 徽章树
export function getBadgeTree() {
return get<{ data: BadgeDimension[] }>('/config/badge/tree')
}
// 添加徽章
export function getBadgeDetail(id: string) {
return get<{ msg: string }>('/config/badge/find', {id})
}
// 添加徽章
export function addBadge(data: Partial<Badge>) {
return post<{ msg: string }>('/config/badge/add', data)
}
// 修改徽章
export function updateBadge(data: Partial<Badge>) {
return post<{ msg: string }>('/config/badge/update', data)
}
// 删除徽章
export function deleteBadge(id: string) {
return get<{ msg: string }>('/config/badge/delete', {id})
}
-2
View File
@@ -1,5 +1,3 @@
// 系统相关 API // 系统相关 API
export * from './system' export * from './system'
// 业务相关 API
export * from './business'
+68
View File
@@ -0,0 +1,68 @@
import { get, post, type PageParams } from '@/lib/request'
import type { SystemOss } from './system'
export interface Post {
id: string
title: string
content: string
location?: string
imgList?: SystemOss[]
ossIds?: string[]
publisher?: {
id: string
nickName: string
avatar?: SystemOss
}
author?: { // Keep for compatibility if needed, or remove
id: string
nickName: string
avatar?: SystemOss
}
viewCount?: number
likeCount?: number
commentCount?: number
hasLiked?: number // 0 or 1
hasReviewed?: number // 0 or 1
createdAt?: string
updatedAt?: string
createdAtStr?: string
commentList?: any[]
likeList?: any[]
}
export interface PostPageParams extends PageParams {
title?: string
hasReviewed?: number // 是否审核通过
}
export interface CreatePostParams {
title: string
content: string
location?: string
ossIds?: string[]
}
export interface CreateCommentParams {
postId: string
content: string
}
// 帖子列表
export function getPostPage(data: PostPageParams) {
return post<{ data: { list: Post[]; total: number } }>('/post/page', data)
}
// 发布帖子
export function publishPost(data: CreatePostParams) {
return post<{ msg: string }>('/post/publish', data)
}
// 点赞帖子
export function likePost(id: string, type: 1 | 2) {
return get<{ msg: string }>('/post/like', { id, type })
}
// 评论帖子
export function commentPost(data: CreateCommentParams) {
return post<{ msg: string }>('/post/comment', data)
}
+56
View File
@@ -0,0 +1,56 @@
import { get, post, type PageParams } from '@/lib/request'
export interface Topic {
id: string
title: string
remark?: string
startTime?: string
endTime?: string
createdAt?: string
updatedAt?: string
}
export interface CreateTopicParams {
title: string
remark?: string
startTime?: string
endTime?: string
}
export interface UpdateTopicParams {
id: string
title?: string
remark?: string
startTime?: string
endTime?: string
}
// 话题列表
export function getTopicList() {
return get<{ data: Topic[] }>('/topic/list')
}
// 话题分页
export function getTopicPage(data: PageParams) {
return post<{ data: { list: Topic[]; total: number } }>('/topic/page', data)
}
// 话题详情
export function getTopicDetail(id: string) {
return get<{ data: Topic }>('/topic/detail', { id })
}
// 添加话题
export function addTopic(data: CreateTopicParams) {
return post<{ msg: string }>('/topic/add', data)
}
// 修改话题
export function updateTopic(data: UpdateTopicParams) {
return post<{ msg: string }>('/topic/add', data) // API uses /topic/add for update as well
}
// 删除话题
export function deleteTopic(ids: string[]) {
return post<{ msg: string }>('/topic/delete', { ids })
}
-125
View File
@@ -1,131 +1,6 @@
import { get, post, type PageParams } from '@/lib/request' import { get, post, type PageParams } from '@/lib/request'
import type { SystemOss } from './system' import type { SystemOss } from './system'
// ==================== 帖子话题 ====================
export interface Topic {
id: string
title: string
remark?: string
startTime?: string
endTime?: string
createdAt?: string
updatedAt?: string
}
export interface CreateTopicParams {
title: string
remark?: string
startTime?: string
endTime?: string
}
export interface UpdateTopicParams {
id: number
title?: string
remark?: string
startTime?: string
endTime?: string
}
// 话题列表
export function getTopicList() {
return get<{ data: Topic[] }>('/topic/list')
}
// 话题分页
export function getTopicPage(data: PageParams) {
return post<{ data: { list: Topic[]; total: number } }>('/topic/page', data)
}
// 话题详情
export function getTopicDetail(id: string) {
return get<{ data: Topic }>('/topic/detail', { id })
}
// 添加话题
export function addTopic(data: CreateTopicParams) {
return post<{ msg: string }>('/topic/add', data)
}
// 修改话题
export function updateTopic(data: UpdateTopicParams) {
return post<{ msg: string }>('/topic/add', data) // API uses /topic/add for update as well
}
// 删除话题
export function deleteTopic(ids: string[]) {
return post<{ msg: string }>('/topic/delete', { ids })
}
// ==================== 帖子/社区 ====================
export interface Post {
id: string
title: string
content: string
location?: string
imgList?: SystemOss[]
ossIds?: string[]
publisher?: {
id: string
nickName: string
avatar?: SystemOss
}
author?: { // Keep for compatibility if needed, or remove
id: string
nickName: string
avatar?: SystemOss
}
viewCount?: number
likeCount?: number
commentCount?: number
hasLiked?: number // 0 or 1
hasReviewed?: number // 0 or 1
createdAt?: string
updatedAt?: string
createdAtStr?: string
commentList?: any[]
likeList?: any[]
}
export interface PostPageParams extends PageParams {
title?: string
hasReviewed?: number // 是否审核通过
}
export interface CreatePostParams {
title: string
content: string
location?: string
ossIds?: string[]
}
export interface CreateCommentParams {
postId: string
content: string
}
// 帖子列表
export function getPostPage(data: PostPageParams) {
return post<{ data: { list: Post[]; total: number } }>('/post/page', data)
}
// 发布帖子
export function publishPost(data: CreatePostParams) {
return post<{ msg: string }>('/post/publish', data)
}
// 点赞帖子
export function likePost(id: string, type: 1 | 2) {
return get<{ msg: string }>('/post/like', { id, type })
}
// 评论帖子
export function commentPost(data: CreateCommentParams) {
return post<{ msg: string }>('/post/comment', data)
}
// ==================== 百科分类 ==================== // ==================== 百科分类 ====================
export interface WikiClass { export interface WikiClass {
+3 -5
View File
@@ -9,7 +9,6 @@ import {
LogOut, LogOut,
ChevronDown, ChevronDown,
Menu, Menu,
X,
FileText, FileText,
Settings, Settings,
Book, Book,
@@ -119,11 +118,11 @@ function NavItemComponent({ item, collapsed, level = 0 }: { item: NavItem; colla
const location = useLocation() const location = useLocation()
// Check if active or child is active // Check if active or child is active
const isActiveLink = location.pathname === item.href
const hasActiveChild = item.children?.some(child => location.pathname.startsWith(child.href)) const hasActiveChild = item.children?.some(child => location.pathname.startsWith(child.href))
useEffect(() => { useEffect(() => {
if (hasActiveChild && !collapsed) { if (hasActiveChild && !collapsed) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setOpen(true) setOpen(true)
} }
}, [hasActiveChild, collapsed]) }, [hasActiveChild, collapsed])
@@ -232,7 +231,7 @@ function Breadcrumb() {
return undefined return undefined
} }
segments.forEach((segment, index) => { segments.forEach((segment) => {
currentPath += `/${segment}` currentPath += `/${segment}`
const menuItem = menus ? findMenuItem(menus, currentPath) : null const menuItem = menus ? findMenuItem(menus, currentPath) : null
@@ -336,7 +335,6 @@ export default function AdminLayout() {
{sidebarOpen && ( {sidebarOpen && (
<div className="flex-1 text-left overflow-hidden"> <div className="flex-1 text-left overflow-hidden">
<p className="text-sm font-medium text-sidebar-foreground truncate leading-none mb-1">{user?.name || user?.account}</p> <p className="text-sm font-medium text-sidebar-foreground truncate leading-none mb-1">{user?.name || user?.account}</p>
<p className="text-xs text-muted-foreground truncate leading-none opacity-80">{user?.role || '管理员'}</p>
</div> </div>
)} )}
{sidebarOpen && <ChevronDown className="h-3 w-3 text-muted-foreground/70" />} {sidebarOpen && <ChevronDown className="h-3 w-3 text-muted-foreground/70" />}
@@ -424,7 +422,7 @@ export default function AdminLayout() {
{/* Page content */} {/* Page content */}
<main className="flex-1 p-4 lg:p-6 bg-muted/10"> <main className="flex-1 p-4 lg:p-6 bg-muted/10">
<div className="mx-auto max-w-7xl animate-fadeIn space-y-4"> <div className="mx-auto w-full animate-fadeIn space-y-6">
<Outlet /> <Outlet />
</div> </div>
</main> </main>
+31 -24
View File
@@ -1,6 +1,6 @@
import { Users, MessageSquare, FolderTree, Leaf, TrendingUp, Eye, ArrowUpRight } from 'lucide-react' import {Users, MessageSquare, FolderTree, Leaf, TrendingUp, Eye, ArrowUpRight} from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
import { mockUsers, mockTopics, mockCategories, mockPlants } from '@/data/mockData' import {mockUsers, mockTopics, mockCategories, mockPlants} from '@/data/mockData'
const stats = [ const stats = [
{ {
@@ -42,10 +42,10 @@ const stats = [
] ]
const recentActivities = [ const recentActivities = [
{ action: '添加了新植物', target: '龟背竹', time: '2分钟前', user: 'admin' }, {action: '添加了新植物', target: '龟背竹', time: '2分钟前', user: 'admin'},
{ action: '发布了新话题', target: '春季植物养护小技巧', time: '1小时前', user: 'editor' }, {action: '发布了新话题', target: '春季植物养护小技巧', time: '1小时前', user: 'editor'},
{ action: '更新了分类', target: '观叶植物', time: '3小时前', user: 'admin' }, {action: '更新了分类', target: '观叶植物', time: '3小时前', user: 'admin'},
{ action: '添加了新用户', target: 'viewer', time: '昨天', user: 'admin' }, {action: '添加了新用户', target: 'viewer', time: '昨天', user: 'admin'},
] ]
export default function DashboardPage() { export default function DashboardPage() {
@@ -58,37 +58,42 @@ export default function DashboardPage() {
<h1 className="text-2xl font-semibold tracking-tight"> 👋</h1> <h1 className="text-2xl font-semibold tracking-tight"> 👋</h1>
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{new Date().toLocaleDateString('zh-CN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} {new Date().toLocaleDateString('zh-CN', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div> </div>
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat, index) => ( {stats.map((stat, index) => (
<Card key={index} className="relative overflow-hidden border-0 shadow-sm hover:shadow-md transition-shadow duration-300"> <Card key={index}
<div className={`absolute inset-0 bg-gradient-to-br ${stat.color} opacity-60`} /> className="relative overflow-hidden border-0 shadow-sm hover:shadow-md transition-shadow duration-300">
<div className={`absolute inset-0 bg-gradient-to-br ${stat.color} opacity-60`}/>
<CardHeader className="relative flex flex-row items-center justify-between pb-2"> <CardHeader className="relative flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
{stat.title} {stat.title}
</CardTitle> </CardTitle>
<div className={`rounded-xl p-2.5 ${stat.iconBg}`}> <div className={`rounded-xl p-2.5 ${stat.iconBg}`}>
<stat.icon className="h-4 w-4" /> <stat.icon className="h-4 w-4"/>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="relative"> <CardContent className="relative">
<div className="text-3xl font-bold tracking-tight">{stat.value}</div> <div className="text-3xl font-bold tracking-tight">{stat.value}</div>
<div className="mt-2 flex items-center gap-1.5 text-xs"> <div className="mt-2 flex items-center gap-1.5 text-xs">
<span <span
className={`flex items-center gap-0.5 rounded-full px-1.5 py-0.5 font-medium ${stat.changeType === 'positive' className={`flex items-center gap-0.5 rounded-full px-1.5 py-0.5 font-medium ${
stat.changeType === 'positive'
? 'bg-emerald-500/10 text-emerald-600' ? 'bg-emerald-500/10 text-emerald-600'
: stat.changeType === 'negative' : 'bg-muted text-muted-foreground' // 移除了对 'negative' 的处理
? 'bg-red-500/10 text-red-600' }`}
: 'bg-muted text-muted-foreground'
}`}
> >
{stat.changeType === 'positive' && <ArrowUpRight className="h-3 w-3" />} {stat.changeType === 'positive' && <ArrowUpRight className="h-3 w-3"/>}
{stat.change} {stat.change}
</span> </span>
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
</div> </div>
</CardContent> </CardContent>
@@ -105,7 +110,7 @@ export default function DashboardPage() {
<div> <div>
<CardTitle className="text-base font-semibold flex items-center gap-2"> <CardTitle className="text-base font-semibold flex items-center gap-2">
<div className="rounded-lg bg-primary/10 p-1.5"> <div className="rounded-lg bg-primary/10 p-1.5">
<MessageSquare className="h-4 w-4 text-primary" /> <MessageSquare className="h-4 w-4 text-primary"/>
</div> </div>
</CardTitle> </CardTitle>
@@ -134,7 +139,7 @@ export default function DashboardPage() {
</p> </p>
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Eye className="h-3 w-3" /> <Eye className="h-3 w-3"/>
{topic.viewCount} {topic.viewCount}
</span> </span>
<span className="font-medium">{topic.authorName}</span> <span className="font-medium">{topic.authorName}</span>
@@ -153,7 +158,7 @@ export default function DashboardPage() {
<div> <div>
<CardTitle className="text-base font-semibold flex items-center gap-2"> <CardTitle className="text-base font-semibold flex items-center gap-2">
<div className="rounded-lg bg-primary/10 p-1.5"> <div className="rounded-lg bg-primary/10 p-1.5">
<TrendingUp className="h-4 w-4 text-primary" /> <TrendingUp className="h-4 w-4 text-primary"/>
</div> </div>
</CardTitle> </CardTitle>
@@ -168,7 +173,8 @@ export default function DashboardPage() {
key={index} key={index}
className="flex items-center gap-4 rounded-lg px-3 py-3 transition-colors hover:bg-muted/30" className="flex items-center gap-4 rounded-lg px-3 py-3 transition-colors hover:bg-muted/30"
> >
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 ring-1 ring-primary/10"> <div
className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 ring-1 ring-primary/10">
<span className="text-xs font-semibold text-primary"> <span className="text-xs font-semibold text-primary">
{activity.user.charAt(0).toUpperCase()} {activity.user.charAt(0).toUpperCase()}
</span> </span>
@@ -193,7 +199,7 @@ export default function DashboardPage() {
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-base font-semibold flex items-center gap-2"> <CardTitle className="text-base font-semibold flex items-center gap-2">
<div className="rounded-lg bg-primary/10 p-1.5"> <div className="rounded-lg bg-primary/10 p-1.5">
<Leaf className="h-4 w-4 text-primary" /> <Leaf className="h-4 w-4 text-primary"/>
</div> </div>
</CardTitle> </CardTitle>
@@ -206,7 +212,8 @@ export default function DashboardPage() {
key={category.id} key={category.id}
className="group flex items-center gap-4 rounded-xl border border-border/50 p-4 transition-all duration-200 hover:bg-muted/30 hover:border-border hover:shadow-sm cursor-pointer" className="group flex items-center gap-4 rounded-xl border border-border/50 p-4 transition-all duration-200 hover:bg-muted/30 hover:border-border hover:shadow-sm cursor-pointer"
> >
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary/10 to-primary/5 text-2xl"> <div
className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary/10 to-primary/5 text-2xl">
{category.icon || '🌱'} {category.icon || '🌱'}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
+1 -1
View File
@@ -124,7 +124,7 @@ export default function TopicsPage() {
title: formData.title, title: formData.title,
content: formData.content, content: formData.content,
authorId: user?.id || '', authorId: user?.id || '',
authorName: user?.username || '', authorName: user?.name || '',
status: formData.status, status: formData.status,
viewCount: 0, viewCount: 0,
likeCount: 0, likeCount: 0,
+1 -1
View File
@@ -35,7 +35,7 @@ import {
getPostPage, getPostPage,
type Post, type Post,
type PostPageParams, type PostPageParams,
} from '@/api/business' } from '@/api/post'
export default function PostsPage() { export default function PostsPage() {
+4 -3
View File
@@ -29,7 +29,7 @@ import {
updateTopic, updateTopic,
deleteTopic, deleteTopic,
type Topic, type Topic,
} from '@/api/business' } from '@/api/topic'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
interface TopicFormData { interface TopicFormData {
@@ -136,7 +136,8 @@ export default function TopicsPage() {
if (!selectedTopic) return if (!selectedTopic) return
try { try {
await deleteTopic([selectedTopic.id]) // Ensure ID is string as backend expects []string
await deleteTopic([String(selectedTopic.id)])
setDeleteDialogOpen(false) setDeleteDialogOpen(false)
setSelectedTopic(null) setSelectedTopic(null)
fetchTopics() fetchTopics()
@@ -161,7 +162,7 @@ export default function TopicsPage() {
} }
if (isEdit && formData.id) { if (isEdit && formData.id) {
await updateTopic({ ...payload, id: Number(formData.id) }) await updateTopic({ ...payload, id: String(formData.id) })
} else { } else {
await addTopic(payload) await addTopic(payload)
} }
+674 -41
View File
@@ -1,51 +1,684 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { useState, useEffect } from 'react'
import { Medal, Award, Lock } from 'lucide-react' import { Award, Trophy, Medal, Grid, Plus, Edit2, Trash2, Eye, Upload, X, Crown, Sprout, Star, Sparkles, Search, ChevronRight, ChevronDown, Layers, Hash } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' 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 { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { getBadgeTree, addBadge, updateBadge, deleteBadge, getBadgeDetail, type BadgeDimension, type Badge } from '@/api/badge'
import { uploadFile, type SystemOss } from '@/api/system'
import { cn } from '@/lib/utils'
// Types
interface BadgeFormData {
id?: string
name: string
description: string
iconId: string
icon?: SystemOss
dimension: string
groupId: string
tier: number
targetAction: string
threshold: number
comparator: string
rewardSunlight: number
sort: number
isHidden: boolean
}
const defaultFormData: BadgeFormData = {
name: '',
description: '',
iconId: '',
dimension: 'EXPERTISE',
groupId: '',
tier: 1,
targetAction: '',
threshold: 1,
comparator: '>=',
rewardSunlight: 10,
sort: 1,
isHidden: false,
}
const DIMENSIONS = [
{ value: 'EXPERTISE', label: '专家成就 (Expertise)' },
{ value: 'PERSISTENCE', label: '勤勉成就 (Persistence)' },
{ value: 'JOURNAL', label: '记录成就 (Journal)' },
{ value: 'DISCOVERY', label: '探索成就 (Discovery)' },
]
export default function BadgeConfigPage() {
const [treeData, setTreeData] = useState<BadgeDimension[]>([])
const [loading, setLoading] = useState(true)
// Dialog State
const [dialogOpen, setDialogOpen] = useState(false)
const [formData, setFormData] = useState<BadgeFormData>(defaultFormData)
const [isEdit, setIsEdit] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [uploading, setUploading] = useState(false)
// Delete Dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [badgeToDelete, setBadgeToDelete] = useState<Badge | null>(null)
// Navigation State
const [expandedDims, setExpandedDims] = useState<string[]>([])
const [selectedDimId, setSelectedDimId] = useState<string>('')
const [selectedGroupId, setSelectedGroupId] = useState<string>('')
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
setLoading(true)
try {
const res = await getBadgeTree()
const data = res.data || []
setTreeData(data)
// Initial Selection if not set
if (data.length > 0 && !selectedDimId) {
// Expand all by default
setExpandedDims(data.map(d => d.dimension))
// Select first group of first dimension
const firstDim = data[0]
setSelectedDimId(firstDim.dimension)
if (firstDim.groups && firstDim.groups.length > 0) {
setSelectedGroupId(firstDim.groups[0].groupId)
}
}
} catch (error) {
console.error('Failed to fetch badge tree:', error)
} finally {
setLoading(false)
}
}
const toggleDim = (dim: string) => {
setExpandedDims(prev =>
prev.includes(dim) ? prev.filter(d => d !== dim) : [...prev, dim]
)
}
const handleSelectGroup = (dimId: string, groupId: string) => {
setSelectedDimId(dimId)
setSelectedGroupId(groupId)
}
const handleAdd = (dimension?: string, groupId?: string) => {
setFormData({
...defaultFormData,
dimension: dimension || selectedDimId || 'EXPERTISE',
groupId: groupId || selectedGroupId || '',
})
setIsEdit(false)
setDialogOpen(true)
}
const handleEdit = async (badge: Badge) => {
try {
const res = await getBadgeDetail(badge.id)
const detail = (res as any).data || badge
setFormData({
id: detail.id,
name: detail.name,
description: detail.description || '',
iconId: detail.iconId || '',
icon: detail.icon,
dimension: detail.dimension,
groupId: detail.groupId,
tier: detail.tier,
targetAction: detail.targetAction,
threshold: detail.threshold,
comparator: detail.comparator,
rewardSunlight: detail.rewardSunlight,
sort: detail.sort,
isHidden: detail.isHidden || false,
})
setIsEdit(true)
setDialogOpen(true)
} catch (e) {
console.error(e)
setFormData({
...defaultFormData,
...badge,
iconId: badge.icon?.id || '',
})
setIsEdit(true)
setDialogOpen(true)
}
}
const handleDelete = (badge: Badge) => {
setBadgeToDelete(badge)
setDeleteDialogOpen(true)
}
const confirmDelete = async () => {
if (!badgeToDelete) return
try {
await deleteBadge(badgeToDelete.id)
setDeleteDialogOpen(false)
fetchData()
} catch (e) {
console.error(e)
}
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
try {
const res = await uploadFile(file)
setFormData(prev => ({
...prev,
iconId: res.data.file.id,
icon: res.data.file,
}))
} catch (err) {
console.error('Upload failed:', err)
} finally {
setUploading(false)
}
}
const handleSubmit = async () => {
if (!formData.name || !formData.targetAction) {
alert('请完善必填信息 (名称, 触发条件)')
return
}
setSubmitting(true)
try {
const { icon, ...payload } = formData
if (isEdit && formData.id) {
await updateBadge(payload)
} else {
await addBadge(payload)
}
setDialogOpen(false)
fetchData()
} catch (e) {
console.error(e)
} finally {
setSubmitting(false)
}
}
// Helper to get icon for tier
const getTierIcon = (tier: number) => {
switch (tier) {
case 3: return <Trophy className="h-4 w-4" />
case 2: return <Medal className="h-4 w-4" />
case 1: return <Award className="h-4 w-4" />
default: return <Award className="h-4 w-4" />
}
}
const getTierLabel = (tier: number) => {
switch (tier) {
case 3: return '金'
case 2: return '银'
case 1: return '铜'
default: return `Tier ${tier}`
}
}
// Helper for Tier Styles
const getTierStyle = (tier: number) => {
switch (tier) {
case 3: return 'border-yellow-400/30 bg-gradient-to-br from-yellow-50/50 to-yellow-100/20 dark:from-yellow-900/10 dark:to-yellow-800/5'
case 2: return 'border-slate-300/30 bg-gradient-to-br from-slate-50/50 to-slate-100/20 dark:from-slate-800/10 dark:to-slate-700/5'
case 1: return 'border-orange-300/30 bg-gradient-to-br from-orange-50/50 to-orange-100/20 dark:from-orange-900/10 dark:to-orange-800/5'
default: return 'border-border bg-card'
}
}
const getTierTextColor = (tier: number) => {
switch (tier) {
case 3: return 'text-yellow-600 dark:text-yellow-400'
case 2: return 'text-slate-600 dark:text-slate-400'
case 1: return 'text-orange-600 dark:text-orange-400'
default: return 'text-foreground'
}
}
// Current Selection Data
const currentDim = treeData.find(d => d.dimension === selectedDimId)
const currentGroup = currentDim?.groups?.find(g => g.groupId === selectedGroupId)
// Stats
const totalBadges = treeData.reduce((acc, dim) => acc + (dim.groups?.reduce((a, g) => a + (g.badges?.length || 0), 0) || 0), 0)
const maxReward = treeData.reduce((acc, dim) => Math.max(acc, dim.groups?.reduce((a, g) => Math.max(a, g.badges?.reduce((b, bd) => Math.max(b, bd.rewardSunlight), 0) || 0), 0) || 0), 0)
const totalDimensions = treeData.length
export default function BadgePage() {
return ( return (
<div className="space-y-6 animate-fadeIn"> <div className="space-y-6 animate-fadeIn pb-10">
<div> {/* Header Stats */}
<h2 className="text-2xl font-bold tracking-tight flex items-center gap-2"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Medal className="h-6 w-6 text-primary" /> <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">
</h2> <CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
<p className="text-muted-foreground mt-1"></p> <Trophy className="h-4 w-4 text-primary" />
</div> </CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="text-2xl font-bold">{totalBadges}</div>
{/* Placeholder Cards to look nice */} <p className="text-xs text-muted-foreground mt-1"></p>
{[1, 2, 3].map((i) => ( </CardContent>
<Card key={i} className="border-border/60 shadow-sm opacity-60"> </Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <Card className="border-l-4 border-l-green-500 shadow-sm hover:shadow-md transition-all">
<CardTitle className="text-sm font-medium"> {i}</CardTitle> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Lock className="h-4 w-4 text-muted-foreground" /> <CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader> <Sprout className="h-4 w-4 text-green-500" />
<CardContent> </CardHeader>
<div className="flex flex-col items-center py-6"> <CardContent>
<div className="h-16 w-16 bg-muted rounded-full flex items-center justify-center mb-4"> <div className="text-2xl font-bold">{maxReward} </div>
<Award className="h-8 w-8 text-muted-foreground/50" /> <p className="text-xs text-muted-foreground mt-1"></p>
</div> </CardContent>
<div className="h-4 w-24 bg-muted rounded animate-pulse mb-2"></div> </Card>
<div className="h-3 w-32 bg-muted/50 rounded animate-pulse"></div> <Card className="border-l-4 border-l-orange-500 shadow-sm hover:shadow-md transition-all">
</div> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
</CardContent> <CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</Card> <Star className="h-4 w-4 text-orange-500" />
))} </CardHeader>
<CardContent>
<Card className="col-span-full border-dashed border-2 shadow-none bg-muted/5"> <div className="text-2xl font-bold">{totalDimensions}</div>
<CardContent className="h-64 flex flex-col items-center justify-center text-center p-6"> <p className="text-xs text-muted-foreground mt-1"></p>
<div className="bg-primary/10 p-4 rounded-full mb-4"> </CardContent>
<Medal className="h-8 w-8 text-primary" /> </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> </div>
<h3 className="text-lg font-semibold text-foreground"></h3> </CardHeader>
<p className="text-muted-foreground max-w-sm mt-2 mb-6"> <CardContent>
线 <div className="text-2xl font-bold text-purple-700">New</div>
</p> <p className="text-xs text-purple-600/80 mt-1"></p>
<Button disabled variant="outline"></Button>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-[240px_1fr] gap-6 items-start">
{/* Left Sidebar: Tree Navigation */}
<Card className="border-border/60 shadow-sm sticky top-6 max-h-[calc(100vh-100px)] overflow-y-auto">
<CardHeader className="pb-3 border-b p-4">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Grid className="h-4 w-4 text-primary" />
</CardTitle>
</CardHeader>
<CardContent className="p-2">
<div className="space-y-1">
{treeData.map(dim => {
const isExpanded = expandedDims.includes(dim.dimension)
const isActiveDim = dim.dimension === selectedDimId
return (
<div key={dim.dimension} className="space-y-1">
{/* Dimension Item */}
<button
onClick={() => toggleDim(dim.dimension)}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-sm font-medium rounded-md transition-colors",
isActiveDim ? "bg-muted/50 text-foreground" : "hover:bg-muted/50 text-muted-foreground hover:text-foreground"
)}
>
<div className="flex items-center gap-2">
{isExpanded ? <ChevronDown className="h-3.5 w-3.5 opacity-50" /> : <ChevronRight className="h-3.5 w-3.5 opacity-50" />}
<span>{dim.label || dim.dimension}</span>
</div>
<span className="text-[10px] bg-muted px-1.5 rounded-full text-muted-foreground/70">
{dim.groups?.length || 0}
</span>
</button>
{/* Groups List */}
{isExpanded && (
<div className="ml-3 pl-3 border-l space-y-0.5 animate-in slide-in-from-left-1 duration-200">
{dim.groups?.map(group => {
const isSelected = group.groupId === selectedGroupId
return (
<button
key={group.groupId}
onClick={() => handleSelectGroup(dim.dimension, group.groupId)}
className={cn(
"w-full flex items-center justify-between px-3 py-1.5 text-xs rounded-md transition-all text-left",
isSelected
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<span className="truncate">{group.groupLabel || group.groupId}</span>
{group.badges?.length ? (
<span className={cn("text-[10px] w-4 h-4 flex items-center justify-center rounded-full ml-1", isSelected ? "bg-primary/20" : "bg-muted")}>
{group.badges.length}
</span>
) : null}
</button>
)
})}
{(!dim.groups || dim.groups.length === 0) && (
<div className="px-3 py-2 text-xs text-muted-foreground/40 italic"></div>
)}
</div>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
{/* Right Content: Stats & Grid */}
<div className="space-y-6">
{currentDim && currentGroup ? (
<Card className="border-border/60 shadow-sm min-h-[500px]">
<CardHeader className="pb-4 border-b">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<span className="flex items-center gap-1"><Layers className="h-3 w-3" /> {currentDim.label || currentDim.dimension}</span>
<ChevronRight className="h-3 w-3" />
<span className="flex items-center gap-1 text-primary font-medium"><Hash className="h-3 w-3" /> {currentGroup.groupLabel || currentGroup.groupId}</span>
</div>
<CardTitle className="text-xl">{currentGroup.groupLabel || currentGroup.groupId}</CardTitle>
<CardDescription>
{currentGroup.badges?.length || 0}
</CardDescription>
</div>
<Button onClick={() => handleAdd(selectedDimId, selectedGroupId)} className="shadow-sm">
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent className="pt-6">
{currentGroup.badges && currentGroup.badges.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{currentGroup.badges.sort((a, b) => a.tier - b.tier).map((badge) => (
<div
key={badge.id}
className={cn(
"group relative rounded-2xl border p-4 transition-all hover:shadow-lg hover:-translate-y-1 bg-card",
getTierStyle(badge.tier)
)}
>
{/* Actions Overlay */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<Button
size="icon"
variant="secondary"
className="h-7 w-7 bg-background/80 backdrop-blur hover:bg-background"
onClick={() => handleEdit(badge)}
>
<Edit2 className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="destructive"
className="h-7 w-7 shadow-sm"
onClick={() => handleDelete(badge)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Tier Label */}
<div className={`text-xs font-bold mb-3 ${getTierTextColor(badge.tier)} flex items-center justify-between`}>
<span className="flex items-center gap-1 bg-background/40 px-2 py-0.5 rounded-full border border-black/5">
{getTierIcon(badge.tier)} {getTierLabel(badge.tier)}
</span>
{badge.isHidden && (
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
<Eye className="h-3 w-3" />
</span>
)}
</div>
{/* Icon & Name */}
<div className="flex flex-col items-center gap-3 py-2 px-1">
<div className={`relative h-16 w-16 flex items-center justify-center transition-transform group-hover:scale-105 duration-300`}>
<div className={`absolute inset-0 blur-xl opacity-0 group-hover:opacity-20 rounded-full transition-opacity ${badge.tier === 3 ? 'bg-yellow-400' :
badge.tier === 2 ? 'bg-slate-400' :
badge.tier === 1 ? 'bg-orange-400' : 'bg-primary'
}`}></div>
{badge.icon && badge.icon.url ? (
<img src={badge.icon.url} className="relative h-full w-full object-contain drop-shadow-sm" alt={badge.name} />
) : (
<div className="scale-150 opacity-80">{getTierIcon(badge.tier)}</div>
)}
</div>
<div className="text-center w-full">
<div className="font-bold text-sm leading-tight text-foreground/90 truncate px-1" title={badge.name}>{badge.name}</div>
<div className="text-[10px] text-muted-foreground mt-1 line-clamp-2 h-7" title={badge.description}>
{badge.description || "暂无描述"}
</div>
</div>
</div>
{/* Info Footer */}
<div className="mt-3 pt-3 border-t border-black/5 dark:border-white/5 grid grid-cols-2 gap-2 text-[10px]">
<div className="flex flex-col">
<span className="text-muted-foreground/70 mb-0.5"></span>
<span className="font-semibold text-foreground/80 truncate font-mono" title={badge.targetAction}>{badge.targetAction}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground/70 mb-0.5"></span>
<span className="font-bold flex items-center gap-1 text-amber-600 bg-amber-50 dark:bg-amber-900/20 px-1.5 py-0.5 rounded">
+{badge.rewardSunlight}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<div className="bg-muted/30 p-4 rounded-full mb-3">
<Grid className="h-8 w-8 opacity-20" />
</div>
<p className="text-sm"></p>
<Button variant="link" onClick={() => handleAdd(selectedDimId, selectedGroupId)} className="mt-2 text-primary">
</Button>
</div>
)}
</CardContent>
</Card>
) : (
<Card className="border-border/60 shadow-sm h-[500px] flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Layers className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p></p>
<p className="text-xs mt-2 opacity-50"></p>
</div>
</Card>
)}
</div>
</div>
)}
{/* Config Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? '编辑徽章' : '新增徽章'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Input value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} placeholder="例如:炼金术士(金)" />
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-col gap-3">
{formData.icon && formData.icon.url ? (
<div className="relative h-32 w-32 border rounded-lg overflow-hidden group bg-muted/20">
<img src={formData.icon.url} className="h-full w-full object-contain p-2" alt="Badge Icon" />
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
onClick={() => setFormData({ ...formData, iconId: '', icon: undefined })}>
<X className="h-8 w-8 text-white" />
</div>
</div>
) : (
<div className="h-32 w-32 border-2 border-dashed rounded-lg bg-muted/10 hover:bg-muted/20 flex flex-col items-center justify-center text-muted-foreground cursor-pointer transition-colors"
onClick={() => document.getElementById('icon-upload')?.click()}>
<Upload className="h-8 w-8 mb-2" />
<span className="text-xs"></span>
</div>
)}
<Input
id="icon-upload"
type="file"
accept="image/*"
onChange={handleUpload}
disabled={uploading}
className="hidden"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })} placeholder="徽章描述文案..." rows={2} />
</div>
{/* Classification */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label> {isEdit && <span className="text-xs text-muted-foreground">()</span>}</Label>
<Select value={formData.dimension} onValueChange={v => setFormData({ ...formData, dimension: v })} disabled={isEdit}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{DIMENSIONS.map(d => <SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> (GroupId) {isEdit && <span className="text-xs text-muted-foreground">()</span>}</Label>
<Input value={formData.groupId} onChange={e => setFormData({ ...formData, groupId: e.target.value })} placeholder="如:fertilizer_master" disabled={isEdit} />
</div>
<div className="space-y-2">
<Label> (Tier)</Label>
<Select value={String(formData.tier)} onValueChange={v => setFormData({ ...formData, tier: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">1 - </SelectItem>
<SelectItem value="2">2 - </SelectItem>
<SelectItem value="3">3 - </SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Condition */}
<div className="grid grid-cols-3 gap-4 border-t pt-4">
<div className="space-y-2">
<Label> *</Label>
<Input value={formData.targetAction} onChange={e => setFormData({ ...formData, targetAction: e.target.value })} placeholder="如:ACT_FERTILIZE" />
</div>
<div className="space-y-2">
<Label></Label>
<Select value={formData.comparator} onValueChange={v => setFormData({ ...formData, comparator: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value=">=">&ge; ()</SelectItem>
<SelectItem value="=">= ()</SelectItem>
<SelectItem value=">">&gt; ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input type="number" value={formData.threshold} onChange={e => setFormData({ ...formData, threshold: Number(e.target.value) })} />
</div>
</div>
{/* Reward & Sort */}
<div className="grid grid-cols-3 gap-4 border-t pt-4">
<div className="space-y-2">
<Label></Label>
<div className="relative">
<Input type="number" value={formData.rewardSunlight} onChange={e => setFormData({ ...formData, rewardSunlight: Number(e.target.value) })} className="pl-8" />
<span className="absolute left-2.5 top-2.5"></span>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input type="number" value={formData.sort} onChange={e => setFormData({ ...formData, sort: Number(e.target.value) })} />
</div>
<div className="space-y-2 flex flex-col justify-center">
<div className="flex items-center space-x-2 mt-6">
<Switch id="hidden-mode" checked={formData.isHidden} onCheckedChange={c => setFormData({ ...formData, isHidden: c })} />
<Label htmlFor="hidden-mode"></Label>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}></Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
"{badgeToDelete?.name}"
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}></Button>
<Button variant="destructive" onClick={confirmDelete}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { Plus, Search, Pencil, Trash2, FolderTree, Image as ImageIcon } from 'lucide-react' import { Plus, Pencil, Trash2, FolderTree, Image as ImageIcon } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -28,7 +28,7 @@ import {
updateWikiClass, updateWikiClass,
deleteWikiClass, deleteWikiClass,
type WikiClass, type WikiClass,
} from '@/api/business' } from '@/api/wiki'
import { uploadFile, type SystemOss } from '@/api/system' import { uploadFile, type SystemOss } from '@/api/system'
interface ClassFormData { interface ClassFormData {
+1 -1
View File
@@ -40,7 +40,7 @@ import {
type Wiki, type Wiki,
type WikiClass, type WikiClass,
type WikiPageParams, type WikiPageParams,
} from '@/api/business' } from '@/api/wiki'
import { uploadFile, type SystemOss } from '@/api/system' import { uploadFile, type SystemOss } from '@/api/system'
interface WikiFormData { interface WikiFormData {