feat: 徽章管理页面
This commit is contained in:
@@ -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})
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// 系统相关 API
|
||||
export * from './system'
|
||||
|
||||
// 业务相关 API
|
||||
export * from './business'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -1,131 +1,6 @@
|
||||
import { get, post, type PageParams } from '@/lib/request'
|
||||
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 {
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Menu,
|
||||
X,
|
||||
FileText,
|
||||
Settings,
|
||||
Book,
|
||||
@@ -119,11 +118,11 @@ function NavItemComponent({ item, collapsed, level = 0 }: { item: NavItem; colla
|
||||
const location = useLocation()
|
||||
|
||||
// Check if active or child is active
|
||||
const isActiveLink = location.pathname === item.href
|
||||
const hasActiveChild = item.children?.some(child => location.pathname.startsWith(child.href))
|
||||
|
||||
useEffect(() => {
|
||||
if (hasActiveChild && !collapsed) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setOpen(true)
|
||||
}
|
||||
}, [hasActiveChild, collapsed])
|
||||
@@ -232,7 +231,7 @@ function Breadcrumb() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
segments.forEach((segment) => {
|
||||
currentPath += `/${segment}`
|
||||
const menuItem = menus ? findMenuItem(menus, currentPath) : null
|
||||
|
||||
@@ -336,7 +335,6 @@ export default function AdminLayout() {
|
||||
{sidebarOpen && (
|
||||
<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-xs text-muted-foreground truncate leading-none opacity-80">{user?.role || '管理员'}</p>
|
||||
</div>
|
||||
)}
|
||||
{sidebarOpen && <ChevronDown className="h-3 w-3 text-muted-foreground/70" />}
|
||||
@@ -424,7 +422,7 @@ export default function AdminLayout() {
|
||||
|
||||
{/* Page content */}
|
||||
<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 />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+31
-24
@@ -1,6 +1,6 @@
|
||||
import { Users, MessageSquare, FolderTree, Leaf, TrendingUp, Eye, ArrowUpRight } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { mockUsers, mockTopics, mockCategories, mockPlants } from '@/data/mockData'
|
||||
import {Users, MessageSquare, FolderTree, Leaf, TrendingUp, Eye, ArrowUpRight} from 'lucide-react'
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||
import {mockUsers, mockTopics, mockCategories, mockPlants} from '@/data/mockData'
|
||||
|
||||
const stats = [
|
||||
{
|
||||
@@ -42,10 +42,10 @@ const stats = [
|
||||
]
|
||||
|
||||
const recentActivities = [
|
||||
{ action: '添加了新植物', target: '龟背竹', time: '2分钟前', user: 'admin' },
|
||||
{ action: '发布了新话题', target: '春季植物养护小技巧', time: '1小时前', user: 'editor' },
|
||||
{ action: '更新了分类', target: '观叶植物', time: '3小时前', user: 'admin' },
|
||||
{ action: '添加了新用户', target: 'viewer', time: '昨天', user: 'admin' },
|
||||
{action: '添加了新植物', target: '龟背竹', time: '2分钟前', user: 'admin'},
|
||||
{action: '发布了新话题', target: '春季植物养护小技巧', time: '1小时前', user: 'editor'},
|
||||
{action: '更新了分类', target: '观叶植物', time: '3小时前', user: 'admin'},
|
||||
{action: '添加了新用户', target: 'viewer', time: '昨天', user: 'admin'},
|
||||
]
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -58,37 +58,42 @@ export default function DashboardPage() {
|
||||
<h1 className="text-2xl font-semibold tracking-tight">欢迎回来 👋</h1>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} 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`} />
|
||||
<Card key={index}
|
||||
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">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
<div className={`rounded-xl p-2.5 ${stat.iconBg}`}>
|
||||
<stat.icon className="h-4 w-4" />
|
||||
<stat.icon className="h-4 w-4"/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative">
|
||||
<div className="text-3xl font-bold tracking-tight">{stat.value}</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs">
|
||||
<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'
|
||||
: stat.changeType === 'negative'
|
||||
? 'bg-red-500/10 text-red-600'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
: 'bg-muted text-muted-foreground' // 移除了对 'negative' 的处理
|
||||
}`}
|
||||
>
|
||||
{stat.changeType === 'positive' && <ArrowUpRight className="h-3 w-3" />}
|
||||
{stat.changeType === 'positive' && <ArrowUpRight className="h-3 w-3"/>}
|
||||
{stat.change}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">较上月</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -105,7 +110,7 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<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>
|
||||
最新话题
|
||||
</CardTitle>
|
||||
@@ -134,7 +139,7 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
<Eye className="h-3 w-3"/>
|
||||
{topic.viewCount}
|
||||
</span>
|
||||
<span className="font-medium">{topic.authorName}</span>
|
||||
@@ -153,7 +158,7 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<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>
|
||||
最近活动
|
||||
</CardTitle>
|
||||
@@ -168,7 +173,8 @@ export default function DashboardPage() {
|
||||
key={index}
|
||||
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">
|
||||
{activity.user.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
@@ -193,7 +199,7 @@ export default function DashboardPage() {
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<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>
|
||||
植物百科概览
|
||||
</CardTitle>
|
||||
@@ -206,7 +212,8 @@ export default function DashboardPage() {
|
||||
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"
|
||||
>
|
||||
<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 || '🌱'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function TopicsPage() {
|
||||
title: formData.title,
|
||||
content: formData.content,
|
||||
authorId: user?.id || '',
|
||||
authorName: user?.username || '',
|
||||
authorName: user?.name || '',
|
||||
status: formData.status,
|
||||
viewCount: 0,
|
||||
likeCount: 0,
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
getPostPage,
|
||||
type Post,
|
||||
type PostPageParams,
|
||||
} from '@/api/business'
|
||||
} from '@/api/post'
|
||||
|
||||
|
||||
export default function PostsPage() {
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
updateTopic,
|
||||
deleteTopic,
|
||||
type Topic,
|
||||
} from '@/api/business'
|
||||
} from '@/api/topic'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TopicFormData {
|
||||
@@ -136,7 +136,8 @@ export default function TopicsPage() {
|
||||
if (!selectedTopic) return
|
||||
|
||||
try {
|
||||
await deleteTopic([selectedTopic.id])
|
||||
// Ensure ID is string as backend expects []string
|
||||
await deleteTopic([String(selectedTopic.id)])
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedTopic(null)
|
||||
fetchTopics()
|
||||
@@ -161,7 +162,7 @@ export default function TopicsPage() {
|
||||
}
|
||||
|
||||
if (isEdit && formData.id) {
|
||||
await updateTopic({ ...payload, id: Number(formData.id) })
|
||||
await updateTopic({ ...payload, id: String(formData.id) })
|
||||
} else {
|
||||
await addTopic(payload)
|
||||
}
|
||||
|
||||
+674
-41
@@ -1,51 +1,684 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Medal, Award, Lock } from 'lucide-react'
|
||||
import { useState, useEffect } from '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 { 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 (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Medal className="h-6 w-6 text-primary" />
|
||||
徽章管理
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">配置用户可获得的徽章及其获取条件。</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Placeholder Cards to look nice */}
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="border-border/60 shadow-sm opacity-60">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">徽章示例 {i}</CardTitle>
|
||||
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center py-6">
|
||||
<div className="h-16 w-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<Award className="h-8 w-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div className="h-4 w-24 bg-muted rounded animate-pulse mb-2"></div>
|
||||
<div className="h-3 w-32 bg-muted/50 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Card className="col-span-full border-dashed border-2 shadow-none bg-muted/5">
|
||||
<CardContent className="h-64 flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="bg-primary/10 p-4 rounded-full mb-4">
|
||||
<Medal className="h-8 w-8 text-primary" />
|
||||
<div className="space-y-6 animate-fadeIn pb-10">
|
||||
{/* 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">{totalBadges}</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">{maxReward} ☀️</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">单徽章最高阳光奖励</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-orange-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-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalDimensions}</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>
|
||||
<h3 className="text-lg font-semibold text-foreground">功能开发中</h3>
|
||||
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
|
||||
徽章配置模块即将上线。届时您将能够自定义徽章图标、达成条件以及显示特效。
|
||||
</p>
|
||||
<Button disabled variant="outline">敬请期待</Button>
|
||||
</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>
|
||||
|
||||
{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=">=">≥ (大于等于)</SelectItem>
|
||||
<SelectItem value="=">= (等于)</SelectItem>
|
||||
<SelectItem value=">">> (大于)</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
updateWikiClass,
|
||||
deleteWikiClass,
|
||||
type WikiClass,
|
||||
} from '@/api/business'
|
||||
} from '@/api/wiki'
|
||||
import { uploadFile, type SystemOss } from '@/api/system'
|
||||
|
||||
interface ClassFormData {
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
type Wiki,
|
||||
type WikiClass,
|
||||
type WikiPageParams,
|
||||
} from '@/api/business'
|
||||
} from '@/api/wiki'
|
||||
import { uploadFile, type SystemOss } from '@/api/system'
|
||||
|
||||
interface WikiFormData {
|
||||
|
||||
Reference in New Issue
Block a user