feat: 实现 Phase 3 — UGC 审核、举报处理、运营配置、多管理员

Phase 3a - UGC 审核队列:
- 题目列表添加来源 Tab 切换(全部/官方/用户投稿)
- UGC 审核对话框,支持通过/拒绝并填写备注
- 添加来源列和审核操作入口

Phase 3b - 举报处理:
- 举报列表页面,支持搜索和筛选
- 举报详情对话框,支持驳回/采纳处理
- 5 种举报原因和 4 种处理状态

Phase 3c - 运营配置:
- 设置页面使用 Tabs 布局
- 活动配置:XP 加成、时间范围、状态管理
- 推送文案:模板管理、变量支持、测试发送
- 通用设置:应用级配置项管理

Phase 3d - 多管理员支持:
- 用户名密码登录(替换 Token 登录)
- 管理员管理页面:创建、删除、重置密码
- 角色区分:admin(管理员)/ moderator(审核员)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wang Zhuoxuan 2026-04-08 15:38:07 +08:00
parent f73246b523
commit 0a31f8634e
31 changed files with 3059 additions and 29 deletions

View File

@ -0,0 +1,199 @@
---
paths:
- "**/*.ts"
- "**/*.tsx"
- "**/*.js"
- "**/*.jsx"
---
# TypeScript/JavaScript Coding Style
> This file extends [common/coding-style.md](../common/coding-style.md) with TypeScript/JavaScript specific content.
## Types and Interfaces
Use types to make public APIs, shared models, and component props explicit, readable, and reusable.
### Public APIs
- Add parameter and return types to exported functions, shared utilities, and public class methods
- Let TypeScript infer obvious local variable types
- Extract repeated inline object shapes into named types or interfaces
```typescript
// WRONG: Exported function without explicit types
export function formatUser(user) {
return `${user.firstName} ${user.lastName}`
}
// CORRECT: Explicit types on public APIs
interface User {
firstName: string
lastName: string
}
export function formatUser(user: User): string {
return `${user.firstName} ${user.lastName}`
}
```
### Interfaces vs. Type Aliases
- Use `interface` for object shapes that may be extended or implemented
- Use `type` for unions, intersections, tuples, mapped types, and utility types
- Prefer string literal unions over `enum` unless an `enum` is required for interoperability
```typescript
interface User {
id: string
email: string
}
type UserRole = 'admin' | 'member'
type UserWithRole = User & {
role: UserRole
}
```
### Avoid `any`
- Avoid `any` in application code
- Use `unknown` for external or untrusted input, then narrow it safely
- Use generics when a value's type depends on the caller
```typescript
// WRONG: any removes type safety
function getErrorMessage(error: any) {
return error.message
}
// CORRECT: unknown forces safe narrowing
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return 'Unexpected error'
}
```
### React Props
- Define component props with a named `interface` or `type`
- Type callback props explicitly
- Do not use `React.FC` unless there is a specific reason to do so
```typescript
interface User {
id: string
email: string
}
interface UserCardProps {
user: User
onSelect: (id: string) => void
}
function UserCard({ user, onSelect }: UserCardProps) {
return <button onClick={() => onSelect(user.id)}>{user.email}</button>
}
```
### JavaScript Files
- In `.js` and `.jsx` files, use JSDoc when types improve clarity and a TypeScript migration is not practical
- Keep JSDoc aligned with runtime behavior
```javascript
/**
* @param {{ firstName: string, lastName: string }} user
* @returns {string}
*/
export function formatUser(user) {
return `${user.firstName} ${user.lastName}`
}
```
## Immutability
Use spread operator for immutable updates:
```typescript
interface User {
id: string
name: string
}
// WRONG: Mutation
function updateUser(user: User, name: string): User {
user.name = name // MUTATION!
return user
}
// CORRECT: Immutability
function updateUser(user: Readonly<User>, name: string): User {
return {
...user,
name
}
}
```
## Error Handling
Use async/await with try-catch and narrow unknown errors safely:
```typescript
interface User {
id: string
email: string
}
declare function riskyOperation(userId: string): Promise<User>
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return 'Unexpected error'
}
const logger = {
error: (message: string, error: unknown) => {
// Replace with your production logger (for example, pino or winston).
}
}
async function loadUser(userId: string): Promise<User> {
try {
const result = await riskyOperation(userId)
return result
} catch (error: unknown) {
logger.error('Operation failed', error)
throw new Error(getErrorMessage(error))
}
}
```
## Input Validation
Use Zod for schema-based validation and infer types from the schema:
```typescript
import { z } from 'zod'
const userSchema = z.object({
email: z.string().email(),
age: z.number().int().min(0).max(150)
})
type UserInput = z.infer<typeof userSchema>
const validated: UserInput = userSchema.parse(input)
```
## Console.log
- No `console.log` statements in production code
- Use proper logging libraries instead
- See hooks for automatic detection

View File

@ -0,0 +1,22 @@
---
paths:
- "**/*.ts"
- "**/*.tsx"
- "**/*.js"
- "**/*.jsx"
---
# TypeScript/JavaScript Hooks
> This file extends [common/hooks.md](../common/hooks.md) with TypeScript/JavaScript specific content.
## PostToolUse Hooks
Configure in `~/.claude/settings.json`:
- **Prettier**: Auto-format JS/TS files after edit
- **TypeScript check**: Run `tsc` after editing `.ts`/`.tsx` files
- **console.log warning**: Warn about `console.log` in edited files
## Stop Hooks
- **console.log audit**: Check all modified files for `console.log` before session ends

View File

@ -0,0 +1,52 @@
---
paths:
- "**/*.ts"
- "**/*.tsx"
- "**/*.js"
- "**/*.jsx"
---
# TypeScript/JavaScript Patterns
> This file extends [common/patterns.md](../common/patterns.md) with TypeScript/JavaScript specific content.
## API Response Format
```typescript
interface ApiResponse<T> {
success: boolean
data?: T
error?: string
meta?: {
total: number
page: number
limit: number
}
}
```
## Custom Hooks Pattern
```typescript
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}
```
## Repository Pattern
```typescript
interface Repository<T> {
findAll(filters?: Filters): Promise<T[]>
findById(id: string): Promise<T | null>
create(data: CreateDto): Promise<T>
update(id: string, data: UpdateDto): Promise<T>
delete(id: string): Promise<void>
}
```

View File

@ -0,0 +1,28 @@
---
paths:
- "**/*.ts"
- "**/*.tsx"
- "**/*.js"
- "**/*.jsx"
---
# TypeScript/JavaScript Security
> This file extends [common/security.md](../common/security.md) with TypeScript/JavaScript specific content.
## Secret Management
```typescript
// NEVER: Hardcoded secrets
const apiKey = "sk-proj-xxxxx"
// ALWAYS: Environment variables
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) {
throw new Error('OPENAI_API_KEY not configured')
}
```
## Agent Support
- Use **security-reviewer** skill for comprehensive security audits

View File

@ -0,0 +1,18 @@
---
paths:
- "**/*.ts"
- "**/*.tsx"
- "**/*.js"
- "**/*.jsx"
---
# TypeScript/JavaScript Testing
> This file extends [common/testing.md](../common/testing.md) with TypeScript/JavaScript specific content.
## E2E Testing
Use **Playwright** as the E2E testing framework for critical user flows.
## Agent Support
- **e2e-runner** - Playwright E2E testing specialist

5
.claude/settings.json Normal file
View File

@ -0,0 +1,5 @@
{
"enabledPlugins": {
"typescript-lsp@claude-plugins-official": true
}
}

View File

@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash(bun --version)",
"mcp__plugin_everything-claude-code_sequential-thinking__sequentialthinking",
"mcp__plugin_everything-claude-code_context7__resolve-library-id",
"mcp__plugin_everything-claude-code_context7__query-docs",
"Bash(cp /tmp/duoqi-admin-init/.gitignore /tmp/duoqi-admin-init/eslint.config.js /tmp/duoqi-admin-init/index.html /tmp/duoqi-admin-init/package.json /tmp/duoqi-admin-init/tsconfig.app.json /tmp/duoqi-admin-init/tsconfig.json /tmp/duoqi-admin-init/tsconfig.node.json /tmp/duoqi-admin-init/vite.config.ts .)",
"Bash(cp -r /tmp/duoqi-admin-init/public .)",
"Bash(cp -r /tmp/duoqi-admin-init/src .)",
"Bash(rm -rf /tmp/duoqi-admin-init)",
"Bash(bun install:*)",
"Bash(bun add:*)",
"Bash(bunx:*)",
"Bash(bun run:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
]
}
}

View File

@ -10,6 +10,8 @@ import SkillTreePage from "@/routes/skill-tree"
import UsersPage from "@/routes/users" import UsersPage from "@/routes/users"
import UserDetailPage from "@/routes/users/$id" import UserDetailPage from "@/routes/users/$id"
import FeedbackPage from "@/routes/feedback" import FeedbackPage from "@/routes/feedback"
import ReportsPage from "@/routes/reports"
import AdminsPage from "@/routes/admins"
import SettingsPage from "@/routes/settings" import SettingsPage from "@/routes/settings"
const router = createBrowserRouter([ const router = createBrowserRouter([
@ -33,6 +35,8 @@ const router = createBrowserRouter([
{ path: "categories", Component: CategoriesPage }, { path: "categories", Component: CategoriesPage },
{ path: "skill-tree", Component: SkillTreePage }, { path: "skill-tree", Component: SkillTreePage },
{ path: "feedback", Component: FeedbackPage }, { path: "feedback", Component: FeedbackPage },
{ path: "reports", Component: ReportsPage },
{ path: "admins", Component: AdminsPage },
{ {
path: "users", path: "users",
children: [ children: [

View File

@ -8,6 +8,9 @@ import {
MessageSquare, MessageSquare,
Settings, Settings,
LogOut, LogOut,
FileCheck,
AlertCircle,
Shield,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAuth } from "@/hooks/use-auth" import { useAuth } from "@/hooks/use-auth"
@ -15,10 +18,13 @@ import { useAuth } from "@/hooks/use-auth"
const navItems = [ const navItems = [
{ to: "/", label: "数据看板", icon: LayoutDashboard, end: true }, { to: "/", label: "数据看板", icon: LayoutDashboard, end: true },
{ to: "/questions", label: "题库管理", icon: BookOpen }, { to: "/questions", label: "题库管理", icon: BookOpen },
{ to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck },
{ to: "/categories", label: "分类管理", icon: FolderOpen }, { to: "/categories", label: "分类管理", icon: FolderOpen },
{ to: "/skill-tree", label: "技能树", icon: TreePine }, { to: "/skill-tree", label: "技能树", icon: TreePine },
{ to: "/users", label: "用户管理", icon: Users }, { to: "/users", label: "用户管理", icon: Users },
{ to: "/feedback", label: "用户反馈", icon: MessageSquare }, { to: "/feedback", label: "用户反馈", icon: MessageSquare },
{ to: "/reports", label: "举报处理", icon: AlertCircle },
{ to: "/admins", label: "管理员", icon: Shield },
{ to: "/settings", label: "系统设置", icon: Settings }, { to: "/settings", label: "系统设置", icon: Settings },
] ]

View File

@ -0,0 +1,233 @@
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { DIFFICULTY_LABELS, QUESTION_SOURCE_LABELS, QUESTION_STATUSES } from "@/lib/constants"
import type { Question } from "@/types/question"
import type { Category } from "@/types/category"
interface UgcReviewDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
question: Question | null
categories: Category[]
onApprove: (note?: string) => Promise<void>
onReject: (note: string) => Promise<void>
}
export function UgcReviewDialog({
open,
onOpenChange,
question,
categories,
onApprove,
onReject,
}: UgcReviewDialogProps) {
const [note, setNote] = useState("")
const [submitting, setSubmitting] = useState(false)
const [action, setAction] = useState<"approve" | "reject" | null>(null)
if (!question) return null
const category = categories.find((c) => c.id === question.categoryId)
async function handleSubmit() {
if (!action) return
setSubmitting(true)
try {
if (action === "approve") {
await onApprove(note || undefined)
} else {
if (!note.trim()) {
// 拒绝时必须填写备注
setSubmitting(false)
return
}
await onReject(note)
}
setNote("")
setAction(null)
onOpenChange(false)
} finally {
setSubmitting(false)
}
}
function handleActionClick(a: "approve" | "reject") {
setAction(a)
if (a === "approve") {
// 通过可以直接提交,备注可选
handleSubmit()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>UGC </DialogTitle>
<DialogDescription>
稿
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 题目基本信息 */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<Label className="text-muted-foreground"></Label>
<p className="font-medium">{category?.name ?? question.categoryId}</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="font-medium">{DIFFICULTY_LABELS[question.difficulty]}</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="font-medium">{QUESTION_SOURCE_LABELS[question.source]}</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<Badge variant="outline">{QUESTION_STATUSES[question.status]}</Badge>
</div>
</div>
<Separator />
{/* 题干 */}
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm leading-relaxed">{question.stem}</p>
</div>
{/* 正确答案 */}
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm font-medium">{question.correctAnswer}</p>
</div>
{/* 干扰项 */}
<div>
<Label className="text-muted-foreground"></Label>
<ul className="mt-1 space-y-1">
{question.distractors.map((d, i) => (
<li key={i} className="text-sm text-muted-foreground">
· {d}
</li>
))}
</ul>
</div>
{/* 知识卡 */}
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm leading-relaxed">{question.knowledgeCardBasic}</p>
</div>
{question.knowledgeCardDeep && (
<div>
<Label className="text-muted-foreground">Pro</Label>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{question.knowledgeCardDeep}
</p>
</div>
)}
<Separator />
{/* 统计信息 */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<Label className="text-muted-foreground"></Label>
<p className="font-medium">{question.stats.timesAnswered}</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="font-medium">{(question.stats.correctRate * 100).toFixed(0)}%</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="font-medium">{(question.stats.avgTimeMs / 1000).toFixed(1)}</p>
</div>
</div>
<Separator />
{/* 审核备注 */}
<div>
<Label htmlFor="review-note"></Label>
<Textarea
id="review-note"
placeholder={
action === "reject"
? "请说明拒绝原因(必填)..."
: "通过理由(选填)..."
}
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
className="mt-1"
/>
{action === "reject" && !note.trim() && (
<p className="mt-1 text-xs text-destructive"></p>
)}
</div>
</div>
<DialogFooter>
<div className="flex gap-2 w-full">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => handleActionClick("approve")}
disabled={submitting}
>
{submitting && action === "approve" ? "处理中..." : "通过并发布"}
</Button>
<Button
type="button"
variant="destructive"
className="flex-1"
onClick={() => {
setAction("reject")
if (!note.trim()) {
// 聚焦到文本框
document.getElementById("review-note")?.focus()
return
}
handleSubmit()
}}
disabled={submitting}
>
{submitting && action === "reject" ? "处理中..." : "拒绝并退回"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
onOpenChange(false)
setNote("")
setAction(null)
}}
disabled={submitting}
>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -14,7 +14,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { StatusBadge } from "@/components/question/StatusBadge" import { StatusBadge } from "@/components/question/StatusBadge"
import { DIFFICULTY_LABELS, QUESTION_STATUSES, TRANSITION_LABELS } from "@/lib/constants" import { DIFFICULTY_LABELS, QUESTION_SOURCE_LABELS, QUESTION_STATUSES, TRANSITION_LABELS } from "@/lib/constants"
import type { Question, QuestionStatus } from "@/types/question" import type { Question, QuestionStatus } from "@/types/question"
import type { Category } from "@/types/category" import type { Category } from "@/types/category"
@ -22,6 +22,7 @@ interface ColumnContext {
categories: Category[] categories: Category[]
onDelete: (question: Question) => void onDelete: (question: Question) => void
onStatusChange: (question: Question, status: QuestionStatus) => void onStatusChange: (question: Question, status: QuestionStatus) => void
onReview?: (question: Question) => void
} }
function getQuestionStatusesForTransition(current: QuestionStatus): QuestionStatus[] { function getQuestionStatusesForTransition(current: QuestionStatus): QuestionStatus[] {
@ -77,6 +78,18 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
) )
}, },
}, },
{
accessorKey: "source",
header: "来源",
cell: ({ row }) => {
const source = row.getValue("source") as "system" | "ugc"
return (
<Badge variant={source === "system" ? "default" : "secondary"}>
{QUESTION_SOURCE_LABELS[source]}
</Badge>
)
},
},
{ {
accessorKey: "difficulty", accessorKey: "difficulty",
header: "难度", header: "难度",
@ -160,6 +173,12 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
</DropdownMenuItem> </DropdownMenuItem>
{question.source === "ugc" && ctx.onReview && (
<DropdownMenuItem onClick={() => ctx.onReview!(question)}>
</DropdownMenuItem>
)}
{transitions.length > 0 && ( {transitions.length > 0 && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />

View File

@ -0,0 +1,340 @@
import { useState, useEffect, useCallback } from "react"
import { Plus, Pencil, Trash2, Calendar } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
fetchEvents,
createEvent,
updateEvent,
updateEventStatus,
deleteEvent,
} from "@/lib/api/settings-api"
import { EVENT_STATUS_LABELS } from "@/lib/constants"
import type { EventConfig, EventStatus } from "@/types/settings"
export function EventConfigTab() {
const [events, setEvents] = useState<EventConfig[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [editingEvent, setEditingEvent] = useState<EventConfig | null>(null)
const [deletingEvent, setDeletingEvent] = useState<EventConfig | null>(null)
const [submitting, setSubmitting] = useState(false)
// 表单状态
const [formData, setFormData] = useState({
name: "",
description: "",
startDate: "",
endDate: "",
xpMultiplier: 1,
})
const loadEvents = useCallback(async () => {
setLoading(true)
try {
const res = await fetchEvents()
setEvents(res.data)
} catch {
setEvents([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadEvents()
}, [loadEvents])
function openCreateDialog() {
setEditingEvent(null)
setFormData({
name: "",
description: "",
startDate: "",
endDate: "",
xpMultiplier: 1,
})
setDialogOpen(true)
}
function openEditDialog(event: EventConfig) {
setEditingEvent(event)
setFormData({
name: event.name,
description: event.description,
startDate: event.startDate.split("T")[0],
endDate: event.endDate.split("T")[0],
xpMultiplier: event.xpMultiplier,
})
setDialogOpen(true)
}
function openDeleteDialog(event: EventConfig) {
setDeletingEvent(event)
setDeleteOpen(true)
}
async function handleSubmit() {
setSubmitting(true)
try {
if (editingEvent) {
await updateEvent(editingEvent.id, formData)
} else {
await createEvent(formData)
}
setDialogOpen(false)
await loadEvents()
} finally {
setSubmitting(false)
}
}
async function handleStatusChange(id: string, status: EventStatus) {
await updateEventStatus(id, status)
await loadEvents()
}
async function handleDelete() {
if (!deletingEvent) return
await deleteEvent(deletingEvent.id)
setDeleteOpen(false)
setDeletingEvent(null)
await loadEvents()
}
return (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground">
XP
</p>
</div>
<Button onClick={openCreateDialog}>
<Plus className="size-4 mr-1" />
</Button>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>XP </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
...
</TableCell>
</TableRow>
) : events.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
events.map((event) => (
<TableRow key={event.id}>
<TableCell className="font-medium">{event.name}</TableCell>
<TableCell className="text-muted-foreground max-w-xs">
<span className="line-clamp-1">{event.description}</span>
</TableCell>
<TableCell className="text-sm">
<div className="flex items-center gap-1 text-muted-foreground">
<Calendar className="size-3" />
{event.startDate.split("T")[0]} ~ {event.endDate.split("T")[0]}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">×{event.xpMultiplier}</Badge>
</TableCell>
<TableCell>
<Select
value={event.status}
onValueChange={(val) => handleStatusChange(event.id, val as EventStatus)}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(EVENT_STATUS_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button variant="ghost" size="icon-xs" onClick={() => openEditDialog(event)}>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-destructive"
onClick={() => openDeleteDialog(event)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 创建/编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingEvent ? "编辑活动" : "新建活动"}</DialogTitle>
<DialogDescription>
XP
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="name"></Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:周末双倍经验"
/>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="活动描述..."
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="startDate"></Label>
<Input
id="startDate"
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
/>
</div>
<div>
<Label htmlFor="endDate"></Label>
<Input
id="endDate"
type="date"
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
/>
</div>
</div>
<div>
<Label htmlFor="xpMultiplier">XP </Label>
<Input
id="xpMultiplier"
type="number"
min="1"
max="10"
step="0.5"
value={formData.xpMultiplier}
onChange={(e) => setFormData({ ...formData, xpMultiplier: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground mt-1">
1.0 2.0
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}>
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{deletingEvent?.name}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-white hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -0,0 +1,266 @@
import { useState, useEffect, useCallback } from "react"
import { Save, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Switch } from "@/components/ui/switch"
import { fetchSettings, updateSetting } from "@/lib/api/settings-api"
// 通用设置项定义(用于展示)
interface SettingItem {
key: string
label: string
type: "text" | "number" | "boolean" | "textarea"
description?: string
category: string
}
const settingItems: SettingItem[] = [
{
key: "daily_xp_goal_default",
label: "默认每日 XP 目标",
type: "number",
description: "新用户的每日 XP 获取目标",
category: "general",
},
{
key: "hearts_max_default",
label: "默认最大红心数",
type: "number",
description: "免费用户最大红心数",
category: "general",
},
{
key: "hearts_restore_minutes",
label: "红心恢复间隔(分钟)",
type: "number",
description: "消耗一颗红心后恢复所需时间",
category: "general",
},
{
key: "streak_requirement",
label: "连续签到要求(题数)",
type: "number",
description: "每日完成多少题才算连续签到",
category: "general",
},
{
key: "maintenance_mode",
label: "维护模式",
type: "boolean",
description: "开启后用户无法访问应用",
category: "general",
},
{
key: "maintenance_message",
label: "维护公告",
type: "textarea",
description: "维护模式下显示的提示信息",
category: "general",
},
{
key: "app_version_min",
label: "最低支持版本",
type: "text",
description: "强制用户更新的最低版本号",
category: "general",
},
]
export function GeneralSettingsTab() {
const [settings, setSettings] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState<string | null>(null)
const [savingAll, setSavingAll] = useState(false)
const loadSettings = useCallback(async () => {
setLoading(true)
try {
const res = await fetchSettings("general")
const settingsMap: Record<string, string> = {}
res.data.forEach((s) => {
settingsMap[s.key] = s.value
})
setSettings(settingsMap)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadSettings()
}, [loadSettings])
async function handleSave(key: string, value: string) {
setSaving(key)
try {
await updateSetting(key, value)
setSettings((prev) => ({ ...prev, [key]: value }))
} finally {
setSaving(null)
}
}
async function handleSaveAll() {
setSavingAll(true)
try {
// 逐个保存所有设置
for (const item of settingItems) {
if (item.key in settings) {
await updateSetting(item.key, settings[item.key])
}
}
} finally {
setSavingAll(false)
}
}
function renderSettingInput(item: SettingItem) {
const value = settings[item.key] || ""
if (item.type === "boolean") {
const boolValue = value === "true"
return (
<div className="flex items-center justify-between">
<div>
<Label>{item.label}</Label>
{item.description && (
<p className="text-xs text-muted-foreground mt-0.5">{item.description}</p>
)}
</div>
<Switch
checked={boolValue}
disabled={saving === item.key}
onCheckedChange={(checked) => handleSave(item.key, checked.toString())}
/>
</div>
)
}
if (item.type === "textarea") {
return (
<div>
<div className="flex items-center justify-between mb-2">
<Label htmlFor={item.key}>{item.label}</Label>
<Button
variant="ghost"
size="sm"
onClick={() => handleSave(item.key, value)}
disabled={saving === item.key}
>
{saving === item.key ? <RefreshCw className="size-3 animate-spin" /> : <Save className="size-3" />}
</Button>
</div>
{item.description && (
<p className="text-xs text-muted-foreground mb-2">{item.description}</p>
)}
<Textarea
id={item.key}
value={value}
onChange={(e) => setSettings((prev) => ({ ...prev, [item.key]: e.target.value }))}
rows={3}
/>
</div>
)
}
if (item.type === "number") {
return (
<div>
<div className="flex items-center justify-between mb-2">
<Label htmlFor={item.key}>{item.label}</Label>
<Button
variant="ghost"
size="sm"
onClick={() => handleSave(item.key, value)}
disabled={saving === item.key}
>
{saving === item.key ? <RefreshCw className="size-3 animate-spin" /> : <Save className="size-3" />}
</Button>
</div>
{item.description && (
<p className="text-xs text-muted-foreground mb-2">{item.description}</p>
)}
<Input
id={item.key}
type="number"
value={value}
onChange={(e) => setSettings((prev) => ({ ...prev, [item.key]: e.target.value }))}
/>
</div>
)
}
// text type
return (
<div>
<div className="flex items-center justify-between mb-2">
<Label htmlFor={item.key}>{item.label}</Label>
<Button
variant="ghost"
size="sm"
onClick={() => handleSave(item.key, value)}
disabled={saving === item.key}
>
{saving === item.key ? <RefreshCw className="size-3 animate-spin" /> : <Save className="size-3" />}
</Button>
</div>
{item.description && (
<p className="text-xs text-muted-foreground mb-2">{item.description}</p>
)}
<Input
id={item.key}
value={value}
onChange={(e) => setSettings((prev) => ({ ...prev, [item.key]: e.target.value }))}
/>
</div>
)
}
return (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Button onClick={handleSaveAll} disabled={savingAll || loading}>
{savingAll ? (
<>
<RefreshCw className="size-4 mr-1 animate-spin" />
...
</>
) : (
<>
<Save className="size-4 mr-1" />
</>
)}
</Button>
</div>
<div className="space-y-6">
{loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<RefreshCw className="size-5 animate-spin mr-2" />
...
</div>
) : (
<div className="grid gap-6 md:grid-cols-2">
{settingItems.map((item) => (
<div key={item.key} className="border rounded-lg p-4">
{renderSettingInput(item)}
</div>
))}
</div>
)}
</div>
</>
)
}

View File

@ -0,0 +1,352 @@
import { useState, useEffect, useCallback } from "react"
import { Plus, Pencil, Trash2, Send, FileText } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
fetchPushTemplates,
createPushTemplate,
updatePushTemplate,
deletePushTemplate,
sendTestPush,
} from "@/lib/api/settings-api"
import { PUSH_TRIGGER_LABELS } from "@/lib/constants"
import type { PushTemplate, PushTriggerType, PushTemplateFormData } from "@/types/settings"
const triggerBadgeColors: Record<PushTriggerType, "default" | "secondary" | "outline"> = {
streak: "default",
achievement: "secondary",
event: "outline",
manual: "default",
}
export function PushTemplateTab() {
const [templates, setTemplates] = useState<PushTemplate[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [editingTemplate, setEditingTemplate] = useState<PushTemplate | null>(null)
const [deletingTemplate, setDeletingTemplate] = useState<PushTemplate | null>(null)
const [submitting, setSubmitting] = useState(false)
const [testSending, setTestSending] = useState(false)
// 表单状态
const [formData, setFormData] = useState<PushTemplateFormData>({
name: "",
title: "",
body: "",
triggerType: "manual",
})
const loadTemplates = useCallback(async () => {
setLoading(true)
try {
const res = await fetchPushTemplates()
setTemplates(res.data)
} catch {
setTemplates([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadTemplates()
}, [loadTemplates])
function openCreateDialog() {
setEditingTemplate(null)
setFormData({
name: "",
title: "",
body: "",
triggerType: "manual",
})
setDialogOpen(true)
}
function openEditDialog(template: PushTemplate) {
setEditingTemplate(template)
setFormData({
name: template.name,
title: template.title,
body: template.body,
triggerType: template.triggerType,
})
setDialogOpen(true)
}
function openDeleteDialog(template: PushTemplate) {
setDeletingTemplate(template)
setDeleteOpen(true)
}
async function handleSubmit() {
setSubmitting(true)
try {
if (editingTemplate) {
await updatePushTemplate(editingTemplate.id, formData)
} else {
await createPushTemplate(formData)
}
setDialogOpen(false)
await loadTemplates()
} finally {
setSubmitting(false)
}
}
async function handleDelete() {
if (!deletingTemplate) return
await deletePushTemplate(deletingTemplate.id)
setDeleteOpen(false)
setDeletingTemplate(null)
await loadTemplates()
}
async function handleSendTest(template: PushTemplate) {
setTestSending(true)
try {
await sendTestPush(template.id)
// TODO: 显示成功提示
} catch {
// TODO: 显示错误提示
} finally {
setTestSending(false)
}
}
return (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Button onClick={openCreateDialog}>
<Plus className="size-4 mr-1" />
</Button>
</div>
{/* 可用变量说明 */}
<div className="rounded-lg border bg-muted/30 p-4 text-sm">
<h3 className="font-medium mb-2"></h3>
<p className="text-muted-foreground">
使 <code className="px-1 py-0.5 rounded bg-background">{"{{variable}}"}</code>
</p>
<ul className="mt-2 space-y-1 text-muted-foreground">
<li> <code className="px-1 py-0.5 rounded bg-background">{"{{userName}}"}</code> - </li>
<li> <code className="px-1 py-0.5 rounded bg-background">{"{{streakDays}}"}</code> - </li>
<li> <code className="px-1 py-0.5 rounded bg-background">{"{{achievementName}}"}</code> - </li>
<li> <code className="px-1 py-0.5 rounded bg-background">{"{{eventName}}"}</code> - </li>
</ul>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
...
</TableCell>
</TableRow>
) : templates.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
templates.map((template) => (
<TableRow key={template.id}>
<TableCell className="font-medium flex items-center gap-2">
<FileText className="size-4 text-muted-foreground" />
{template.name}
</TableCell>
<TableCell>
<Badge variant={triggerBadgeColors[template.triggerType]}>
{PUSH_TRIGGER_LABELS[template.triggerType]}
</Badge>
</TableCell>
<TableCell className="text-sm">{template.title}</TableCell>
<TableCell className="text-muted-foreground text-sm max-w-xs">
<span className="line-clamp-1">{template.body}</span>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="outline"
size="icon-xs"
onClick={() => handleSendTest(template)}
disabled={testSending}
title="发送测试推送"
>
<Send className="size-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" onClick={() => openEditDialog(template)}>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-destructive"
onClick={() => openDeleteDialog(template)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 创建/编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingTemplate ? "编辑模板" : "新建模板"}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="tpl-name"></Label>
<Input
id="tpl-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:连胜提醒"
/>
</div>
<div>
<Label htmlFor="tpl-trigger"></Label>
<Select
value={formData.triggerType}
onValueChange={(val) => setFormData({ ...formData, triggerType: val as PushTriggerType })}
>
<SelectTrigger id="tpl-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PUSH_TRIGGER_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="tpl-title"></Label>
<Input
id="tpl-title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="例如:恭喜获得成就!"
/>
</div>
<div>
<Label htmlFor="tpl-body"></Label>
<Textarea
id="tpl-body"
value={formData.body}
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
placeholder='例如:{{userName}},你已连续签到 {{streakDays}} 天!'
rows={4}
/>
<p className="text-xs text-muted-foreground mt-1">
使 {`{{variable}}`}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}>
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{deletingTemplate?.name}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-white hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -0,0 +1,33 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

49
src/lib/api/admin-api.ts Normal file
View File

@ -0,0 +1,49 @@
import { apiClient } from "@/lib/api-client"
import type { ApiResponse } from "@/types/api"
import type { Admin, AdminLoginForm, AdminSession, CreateAdminForm } from "@/types/admin"
// 认证
export async function loginAdmin(
credentials: AdminLoginForm
): Promise<ApiResponse<AdminSession>> {
return apiClient.post("auth/login", { json: credentials }).json<ApiResponse<AdminSession>>()
}
export async function fetchMe(): Promise<ApiResponse<Admin>> {
return apiClient.get("auth/me").json<ApiResponse<Admin>>()
}
// 管理员管理
export async function fetchAdmins(): Promise<ApiResponse<Admin[]>> {
return apiClient.get("admins").json<ApiResponse<Admin[]>>()
}
export async function fetchAdmin(id: string): Promise<ApiResponse<Admin>> {
return apiClient.get(`admins/${id}`).json<ApiResponse<Admin>>()
}
export async function createAdmin(
data: CreateAdminForm
): Promise<ApiResponse<Admin>> {
return apiClient.post("admins", { json: data }).json<ApiResponse<Admin>>()
}
export async function updateAdmin(
id: string,
data: Partial<CreateAdminForm>
): Promise<ApiResponse<Admin>> {
return apiClient.put(`admins/${id}`, { json: data }).json<ApiResponse<Admin>>()
}
export async function deleteAdmin(id: string): Promise<ApiResponse<{ id: string }>> {
return apiClient.delete(`admins/${id}`).json<ApiResponse<{ id: string }>>()
}
export async function resetAdminPassword(
id: string,
newPassword: string
): Promise<ApiResponse<Admin>> {
return apiClient
.post(`admins/${id}/reset-password`, { json: { password: newPassword } })
.json<ApiResponse<Admin>>()
}

View File

@ -9,6 +9,7 @@ export interface FetchQuestionsParams {
status?: QuestionStatus status?: QuestionStatus
categoryId?: string categoryId?: string
difficulty?: Difficulty difficulty?: Difficulty
source?: "system" | "ugc"
} }
export async function fetchQuestions( export async function fetchQuestions(
@ -21,6 +22,7 @@ export async function fetchQuestions(
if (params.status) searchParams.set("status", params.status) if (params.status) searchParams.set("status", params.status)
if (params.categoryId) searchParams.set("categoryId", params.categoryId) if (params.categoryId) searchParams.set("categoryId", params.categoryId)
if (params.difficulty) searchParams.set("difficulty", String(params.difficulty)) if (params.difficulty) searchParams.set("difficulty", String(params.difficulty))
if (params.source) searchParams.set("source", params.source)
return apiClient return apiClient
.get("questions", { searchParams }) .get("questions", { searchParams })

55
src/lib/api/report-api.ts Normal file
View File

@ -0,0 +1,55 @@
import { apiClient } from "@/lib/api-client"
import type { PaginatedResponse, ApiResponse } from "@/types/api"
import type { Report, ReportFormData, ReportReason, ReportStatus } from "@/types/report"
export interface FetchReportsParams {
page?: number
limit?: number
search?: string
status?: ReportStatus
reason?: ReportReason
}
export async function fetchReports(
params: FetchReportsParams = {}
): Promise<PaginatedResponse<Report>> {
const searchParams = new URLSearchParams()
if (params.page) searchParams.set("page", String(params.page))
if (params.limit) searchParams.set("limit", String(params.limit))
if (params.search) searchParams.set("search", params.search)
if (params.status) searchParams.set("status", params.status)
if (params.reason) searchParams.set("reason", params.reason)
return apiClient
.get("reports", { searchParams })
.json<PaginatedResponse<Report>>()
}
export async function fetchReport(id: string): Promise<ApiResponse<Report>> {
return apiClient.get(`reports/${id}`).json<ApiResponse<Report>>()
}
export async function updateReport(
id: string,
data: ReportFormData
): Promise<ApiResponse<Report>> {
return apiClient.put(`reports/${id}`, { json: data }).json<ApiResponse<Report>>()
}
export async function dismissReport(
id: string,
note?: string
): Promise<ApiResponse<Report>> {
return apiClient
.post(`reports/${id}/dismiss`, { json: { note } })
.json<ApiResponse<Report>>()
}
export async function resolveReport(
id: string,
note?: string
): Promise<ApiResponse<Report>> {
return apiClient
.post(`reports/${id}/resolve`, { json: { note } })
.json<ApiResponse<Report>>()
}

View File

@ -0,0 +1,93 @@
import { apiClient } from "@/lib/api-client"
import type { ApiResponse } from "@/types/api"
import type {
EventConfig,
EventFormData,
PushTemplate,
PushTemplateFormData,
AppSetting,
} from "@/types/settings"
// ========== 活动配置 ==========
export async function fetchEvents(): Promise<ApiResponse<EventConfig[]>> {
return apiClient.get("events").json<ApiResponse<EventConfig[]>>()
}
export async function fetchEvent(id: string): Promise<ApiResponse<EventConfig>> {
return apiClient.get(`events/${id}`).json<ApiResponse<EventConfig>>()
}
export async function createEvent(data: EventFormData): Promise<ApiResponse<EventConfig>> {
return apiClient.post("events", { json: data }).json<ApiResponse<EventConfig>>()
}
export async function updateEvent(
id: string,
data: Partial<EventFormData>
): Promise<ApiResponse<EventConfig>> {
return apiClient.put(`events/${id}`, { json: data }).json<ApiResponse<EventConfig>>()
}
export async function updateEventStatus(
id: string,
status: "draft" | "active" | "ended"
): Promise<ApiResponse<EventConfig>> {
return apiClient
.patch(`events/${id}/status`, { json: { status } })
.json<ApiResponse<EventConfig>>()
}
export async function deleteEvent(id: string): Promise<ApiResponse<{ id: string }>> {
return apiClient.delete(`events/${id}`).json<ApiResponse<{ id: string }>>()
}
// ========== 推送模板 ==========
export async function fetchPushTemplates(): Promise<ApiResponse<PushTemplate[]>> {
return apiClient.get("push-templates").json<ApiResponse<PushTemplate[]>>()
}
export async function fetchPushTemplate(id: string): Promise<ApiResponse<PushTemplate>> {
return apiClient.get(`push-templates/${id}`).json<ApiResponse<PushTemplate>>()
}
export async function createPushTemplate(
data: PushTemplateFormData
): Promise<ApiResponse<PushTemplate>> {
return apiClient.post("push-templates", { json: data }).json<ApiResponse<PushTemplate>>()
}
export async function updatePushTemplate(
id: string,
data: Partial<PushTemplateFormData>
): Promise<ApiResponse<PushTemplate>> {
return apiClient.put(`push-templates/${id}`, { json: data }).json<ApiResponse<PushTemplate>>()
}
export async function deletePushTemplate(id: string): Promise<ApiResponse<{ id: string }>> {
return apiClient.delete(`push-templates/${id}`).json<ApiResponse<{ id: string }>>()
}
// 发送测试推送
export async function sendTestPush(
templateId: string
): Promise<ApiResponse<{ success: boolean; message?: string }>> {
return apiClient
.post(`push-templates/${templateId}/test`)
.json<ApiResponse<{ success: boolean; message?: string }>>()
}
// ========== 通用设置 ==========
export async function fetchSettings(category?: string): Promise<ApiResponse<AppSetting[]>> {
const url = category ? `settings?category=${category}` : "settings"
return apiClient.get(url).json<ApiResponse<AppSetting[]>>()
}
export async function updateSetting(
key: string,
value: string
): Promise<ApiResponse<AppSetting>> {
return apiClient.put(`settings/${key}`, { json: { value } }).json<ApiResponse<AppSetting>>()
}

View File

@ -1,5 +1,7 @@
import { AUTH_STORAGE_KEY } from "./constants" import { AUTH_STORAGE_KEY } from "./constants"
const CURRENT_ADMIN_ID_KEY = "duoqi_admin_current_id"
export function getStoredToken(): string | null { export function getStoredToken(): string | null {
return localStorage.getItem(AUTH_STORAGE_KEY) return localStorage.getItem(AUTH_STORAGE_KEY)
} }
@ -10,4 +12,13 @@ export function setStoredToken(token: string): void {
export function removeStoredToken(): void { export function removeStoredToken(): void {
localStorage.removeItem(AUTH_STORAGE_KEY) localStorage.removeItem(AUTH_STORAGE_KEY)
localStorage.removeItem(CURRENT_ADMIN_ID_KEY)
}
export function setCurrentAdminId(id: string): void {
localStorage.setItem(CURRENT_ADMIN_ID_KEY, id)
}
export function getCurrentAdminId(): string | null {
return localStorage.getItem(CURRENT_ADMIN_ID_KEY)
} }

View File

@ -2,6 +2,9 @@ import type { CategoryStatus } from "@/types/category"
import type { FeedbackStatus, FeedbackType } from "@/types/feedback" import type { FeedbackStatus, FeedbackType } from "@/types/feedback"
import type { QuestionStatus } from "@/types/question" import type { QuestionStatus } from "@/types/question"
import type { UserTier } from "@/types/user" import type { UserTier } from "@/types/user"
import type { ReportReason, ReportStatus } from "@/types/report"
import type { EventStatus, PushTriggerType, SettingCategory } from "@/types/settings"
import type { AdminRole } from "@/types/admin"
export const QUESTION_STATUSES = { export const QUESTION_STATUSES = {
draft: "草稿", draft: "草稿",
@ -61,3 +64,47 @@ export const TIER_DESCRIPTIONS: Record<UserTier, string> = {
pro: "无广告 + 无限红心 + 深度知识卡 + 连胜自动修复", pro: "无广告 + 无限红心 + 深度知识卡 + 连胜自动修复",
proplus: "Pro 全部权益 + 专属题包 + 吉祥物皮肤 + AI 知识问答", proplus: "Pro 全部权益 + 专属题包 + 吉祥物皮肤 + AI 知识问答",
} }
export const QUESTION_SOURCE_LABELS: Record<"system" | "ugc", string> = {
system: "官方",
ugc: "用户投稿",
}
export const REPORT_REASON_LABELS: Record<ReportReason, string> = {
inaccurate: "内容错误",
inappropriate: "不当内容",
copyright: "版权问题",
spam: "垃圾信息",
other: "其他",
}
export const REPORT_STATUS_LABELS: Record<ReportStatus, string> = {
pending: "待处理",
reviewing: "处理中",
resolved: "已处理",
dismissed: "已驳回",
}
export const EVENT_STATUS_LABELS: Record<EventStatus, string> = {
draft: "草稿",
active: "进行中",
ended: "已结束",
}
export const PUSH_TRIGGER_LABELS: Record<PushTriggerType, string> = {
streak: "连胜触发",
achievement: "成就触发",
event: "活动触发",
manual: "手动发送",
}
export const SETTING_CATEGORY_LABELS: Record<SettingCategory, string> = {
event: "活动配置",
push: "推送配置",
general: "通用设置",
}
export const ADMIN_ROLE_LABELS: Record<AdminRole, string> = {
admin: "管理员",
moderator: "审核员",
}

389
src/routes/admins/index.tsx Normal file
View File

@ -0,0 +1,389 @@
import { useState, useEffect, useCallback } from "react"
import { Plus, Trash2, Shield, ShieldAlert, Key } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { fetchAdmins, createAdmin, deleteAdmin, resetAdminPassword } from "@/lib/api/admin-api"
import { ADMIN_ROLE_LABELS } from "@/lib/constants"
import type { Admin, AdminRole, CreateAdminForm } from "@/types/admin"
const roleIcons = {
admin: Shield,
moderator: ShieldAlert,
}
const roleBadgeVariants: Record<AdminRole, "default" | "secondary"> = {
admin: "default",
moderator: "secondary",
}
export default function AdminsPage() {
const [admins, setAdmins] = useState<Admin[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [resetPasswordOpen, setResetPasswordOpen] = useState(false)
const [selectedAdmin, setSelectedAdmin] = useState<Admin | null>(null)
const [submitting, setSubmitting] = useState(false)
// 表单状态
const [formData, setFormData] = useState<CreateAdminForm>({
username: "",
password: "",
role: "moderator",
})
// 重置密码表单
const [resetPasswordData, setResetPasswordData] = useState({
newPassword: "",
confirmPassword: "",
})
const loadAdmins = useCallback(async () => {
setLoading(true)
try {
const res = await fetchAdmins()
setAdmins(res.data)
} catch {
setAdmins([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadAdmins()
}, [loadAdmins])
function openCreateDialog() {
setSelectedAdmin(null)
setFormData({
username: "",
password: "",
role: "moderator",
})
setDialogOpen(true)
}
function openDeleteDialog(admin: Admin) {
setSelectedAdmin(admin)
setDeleteOpen(true)
}
function openResetPasswordDialog(admin: Admin) {
setSelectedAdmin(admin)
setResetPasswordData({ newPassword: "", confirmPassword: "" })
setResetPasswordOpen(true)
}
async function handleSubmit() {
if (!formData.password) {
return
}
setSubmitting(true)
try {
await createAdmin(formData)
setDialogOpen(false)
await loadAdmins()
} finally {
setSubmitting(false)
}
}
async function handleDelete() {
if (!selectedAdmin) return
await deleteAdmin(selectedAdmin.id)
setDeleteOpen(false)
setSelectedAdmin(null)
await loadAdmins()
}
async function handleResetPassword() {
if (!selectedAdmin || resetPasswordData.newPassword !== resetPasswordData.confirmPassword) {
return
}
setSubmitting(true)
try {
await resetAdminPassword(selectedAdmin.id, resetPasswordData.newPassword)
setResetPasswordOpen(false)
// TODO: 显示成功提示
} finally {
setSubmitting(false)
}
}
// 获取当前管理员的信息(从 localStorage
const currentAdminId = localStorage.getItem("duoqi_admin_current_id")
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Button onClick={openCreateDialog}>
<Plus className="size-4 mr-1" />
</Button>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
...
</TableCell>
</TableRow>
) : admins.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
admins.map((admin) => {
const RoleIcon = roleIcons[admin.role]
const isCurrentUser = admin.id === currentAdminId
return (
<TableRow key={admin.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<RoleIcon className="size-4 text-muted-foreground" />
{admin.username}
{isCurrentUser && (
<Badge variant="outline" className="text-xs"></Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={roleBadgeVariants[admin.role]}>
{ADMIN_ROLE_LABELS[admin.role]}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(admin.createdAt).toLocaleString("zh-CN")}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{admin.lastLoginAt
? new Date(admin.lastLoginAt).toLocaleString("zh-CN")
: "从未登录"}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="outline"
size="icon-xs"
onClick={() => openResetPasswordDialog(admin)}
title="重置密码"
>
<Key className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-destructive"
onClick={() => openDeleteDialog(admin)}
disabled={isCurrentUser}
title={isCurrentUser ? "不能删除当前账号" : "删除"}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
{/* 创建管理员对话框 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="username"></Label>
<Input
id="username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="请输入用户名"
/>
</div>
<div>
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="请输入密码(至少 6 位)"
/>
</div>
<div>
<Label htmlFor="role"></Label>
<Select
value={formData.role}
onValueChange={(val) => setFormData({ ...formData, role: val as AdminRole })}
>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ADMIN_ROLE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}>
</Button>
<Button onClick={handleSubmit} disabled={submitting || !formData.username || !formData.password}>
{submitting ? "创建中..." : "创建"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{selectedAdmin?.username}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-white hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 重置密码对话框 */}
<Dialog open={resetPasswordOpen} onOpenChange={setResetPasswordOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
"{selectedAdmin?.username}"
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="new-password"></Label>
<Input
id="new-password"
type="password"
value={resetPasswordData.newPassword}
onChange={(e) =>
setResetPasswordData({ ...resetPasswordData, newPassword: e.target.value })
}
placeholder="请输入新密码(至少 6 位)"
/>
</div>
<div>
<Label htmlFor="confirm-password"></Label>
<Input
id="confirm-password"
type="password"
value={resetPasswordData.confirmPassword}
onChange={(e) =>
setResetPasswordData({ ...resetPasswordData, confirmPassword: e.target.value })
}
placeholder="请再次输入新密码"
/>
{resetPasswordData.newPassword !== resetPasswordData.confirmPassword && (
<p className="text-sm text-destructive mt-1"></p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setResetPasswordOpen(false)} disabled={submitting}>
</Button>
<Button
onClick={handleResetPassword}
disabled={submitting || !resetPasswordData.newPassword || resetPasswordData.newPassword !== resetPasswordData.confirmPassword}
>
{submitting ? "重置中..." : "确认重置"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -3,16 +3,18 @@ import { useNavigate } from "react-router"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod/v4" import { z } from "zod/v4"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { apiClient } from "@/lib/api-client" import { Eye, EyeOff } from "lucide-react"
import { loginAdmin } from "@/lib/api/admin-api"
import { useAuth } from "@/hooks/use-auth" import { useAuth } from "@/hooks/use-auth"
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"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import type { LoginResponse } from "@/types/api" import type { AdminSession } from "@/types/admin"
const loginSchema = z.object({ const loginSchema = z.object({
token: z.string().min(1, "请输入 Admin Token"), username: z.string().min(1, "请输入用户名"),
password: z.string().min(1, "请输入密码"),
}) })
type LoginForm = z.infer<typeof loginSchema> type LoginForm = z.infer<typeof loginSchema>
@ -21,6 +23,7 @@ export default function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { login } = useAuth() const { login } = useAuth()
const [error, setError] = useState("") const [error, setError] = useState("")
const [showPassword, setShowPassword] = useState(false)
const { const {
register, register,
@ -28,19 +31,29 @@ export default function LoginPage() {
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<LoginForm>({ } = useForm<LoginForm>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",
},
}) })
async function onSubmit(data: LoginForm) { async function onSubmit(data: LoginForm) {
setError("") setError("")
try { try {
const response = await apiClient const response = await loginAdmin(data)
.post("admin/auth/login", { json: { token: data.token } }) const session: AdminSession = response.data
.json<LoginResponse>()
login(response.jwt, response.admin) // 将 Admin 对象转换为旧格式的 admin 对象以保持兼容
const legacyAdmin = {
id: session.admin.id,
username: session.admin.username,
role: session.admin.role,
}
login(session.token, legacyAdmin)
navigate("/") navigate("/")
} catch { } catch {
setError("登录失败,请检查 Token 是否正确") setError("登录失败,请检查用户名和密码")
} }
} }
@ -49,20 +62,45 @@ export default function LoginPage() {
<Card className="w-full max-w-sm"> <Card className="w-full max-w-sm">
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle className="text-xl"></CardTitle> <CardTitle className="text-xl"></CardTitle>
<CardDescription> Admin Token </CardDescription> <CardDescription>使</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="token">Admin Token</Label> <Label htmlFor="username"></Label>
<Input <Input
id="token" id="username"
type="password" placeholder="请输入用户名"
placeholder="请输入 Token" autoComplete="username"
{...register("token")} {...register("username")}
/> />
{errors.token && ( {errors.username && (
<p className="text-sm text-destructive">{errors.token.message}</p> <p className="text-sm text-destructive">{errors.username.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="请输入密码"
autoComplete="current-password"
{...register("password")}
/>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
</div>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)} )}
</div> </div>

View File

@ -1,12 +1,12 @@
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { Link } from "react-router" import { Link, useSearchParams } from "react-router"
import { import {
useReactTable, useReactTable,
getCoreRowModel, getCoreRowModel,
flexRender, flexRender,
type ColumnDef, type ColumnDef,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Plus, Search, Trash2, EyeOff, Send, Download } from "lucide-react" import { Plus, Search, Trash2, EyeOff, Send, Download, FileCheck } 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 { import {
@ -16,6 +16,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { import {
Table, Table,
TableBody, TableBody,
@ -37,6 +38,7 @@ import {
import { getColumns } from "@/components/question/columns" import { getColumns } from "@/components/question/columns"
import { StatusTransitionDialog } from "@/components/question/StatusTransitionDialog" import { StatusTransitionDialog } from "@/components/question/StatusTransitionDialog"
import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDialog" import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDialog"
import { UgcReviewDialog } from "@/components/question/UgcReviewDialog"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchOperateQuestions } from "@/lib/api/question-api" import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchOperateQuestions } from "@/lib/api/question-api"
import { fetchCategories } from "@/lib/api/category-api" import { fetchCategories } from "@/lib/api/category-api"
@ -47,7 +49,16 @@ import type { Category } from "@/types/category"
const PAGE_SIZE = 20 const PAGE_SIZE = 20
type SourceTab = "all" | "system" | "ugc"
const SOURCE_TABS = [
{ value: "all" as const, label: "全部题目" },
{ value: "system" as const, label: "官方题库" },
{ value: "ugc" as const, label: "用户投稿" },
] as const
export default function QuestionsPage() { export default function QuestionsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [questions, setQuestions] = useState<Question[]>([]) const [questions, setQuestions] = useState<Question[]>([])
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -58,6 +69,22 @@ export default function QuestionsPage() {
const [categoryFilter, setCategoryFilter] = useState<string>("all") const [categoryFilter, setCategoryFilter] = useState<string>("all")
const [difficultyFilter, setDifficultyFilter] = useState<string>("all") const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
// 从 URL 查询参数读取 source如果没有则默认为 "all"
const [sourceTab, setSourceTab] = useState<SourceTab>(
() => (searchParams.get("source") as SourceTab) || "all"
)
// 当 sourceTab 改变时,更新 URL
useEffect(() => {
const newParams = new URLSearchParams(searchParams)
if (sourceTab === "all") {
newParams.delete("source")
} else {
newParams.set("source", sourceTab)
}
setSearchParams(newParams, { replace: true })
}, [sourceTab, searchParams, setSearchParams])
// 删除对话框 // 删除对话框
const [deleteOpen, setDeleteOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false)
const [deletingQuestion, setDeletingQuestion] = useState<Question | null>(null) const [deletingQuestion, setDeletingQuestion] = useState<Question | null>(null)
@ -70,6 +97,10 @@ export default function QuestionsPage() {
// 批量导入对话框 // 批量导入对话框
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
// UGC 审核对话框
const [ugcReviewOpen, setUgcReviewOpen] = useState(false)
const [ugcReviewQuestion, setUgcReviewQuestion] = useState<Question | null>(null)
// 批量操作 // 批量操作
const [batchConfirmOpen, setBatchConfirmOpen] = useState(false) const [batchConfirmOpen, setBatchConfirmOpen] = useState(false)
const [batchAction, setBatchAction] = useState<"publish" | "archive" | "delete">("delete") const [batchAction, setBatchAction] = useState<"publish" | "archive" | "delete">("delete")
@ -95,6 +126,7 @@ export default function QuestionsPage() {
difficulty: difficultyFilter !== "all" difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty) ? (Number(difficultyFilter) as Difficulty)
: undefined, : undefined,
source: sourceTab !== "all" ? sourceTab : undefined,
}) })
setQuestions(res.data) setQuestions(res.data)
setTotal(res.pagination.total) setTotal(res.pagination.total)
@ -103,7 +135,7 @@ export default function QuestionsPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [page, search, statusFilter, categoryFilter, difficultyFilter]) }, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab])
useEffect(() => { useEffect(() => {
loadQuestions() loadQuestions()
@ -157,10 +189,35 @@ export default function QuestionsPage() {
setBatchConfirmOpen(true) setBatchConfirmOpen(true)
} }
// UGC 审核
function openUgcReview(question: Question) {
setUgcReviewQuestion(question)
setUgcReviewOpen(true)
}
async function handleApproveUgc(_note?: string) {
if (!ugcReviewQuestion) return
await updateQuestionStatus(ugcReviewQuestion.id, "published")
setUgcReviewOpen(false)
setUgcReviewQuestion(null)
await loadQuestions()
}
async function handleRejectUgc(_note: string) {
if (!ugcReviewQuestion) return
// TODO: 这里可以添加 API 调用来保存审核备注
// 暂时只更新状态
await updateQuestionStatus(ugcReviewQuestion.id, "draft")
setUgcReviewOpen(false)
setUgcReviewQuestion(null)
await loadQuestions()
}
const columns = getColumns({ const columns = getColumns({
categories, categories,
onDelete: openDelete, onDelete: openDelete,
onStatusChange: handleStatusChange, onStatusChange: handleStatusChange,
onReview: sourceTab === "ugc" ? openUgcReview : undefined,
}) })
// 选择列 + 数据列 // 选择列 + 数据列
@ -224,15 +281,34 @@ export default function QuestionsPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* 页面头部 */} {/* 页面头部 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-2">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<Tabs value={sourceTab} onValueChange={(val) => { setSourceTab(val as SourceTab); setPage(1) }}>
<TabsList>
{SOURCE_TABS.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
{sourceTab === "ugc" && (
<Button variant="outline" size="sm" onClick={() => {/* UGC 批量审核 - 后续实现 */}}>
<FileCheck className="mr-1 size-4" />
</Button>
)}
<Button variant="outline" size="sm" disabled={exporting} onClick={handleExport}> <Button variant="outline" size="sm" disabled={exporting} onClick={handleExport}>
<Download className="mr-1 size-4" /> <Download className="mr-1 size-4" />
{exporting ? "导出中..." : "导出 CSV"} {exporting ? "导出中..." : "导出 CSV"}
</Button> </Button>
{sourceTab === "system" && (
<Button variant="outline" onClick={() => setImportOpen(true)}> <Button variant="outline" onClick={() => setImportOpen(true)}>
</Button> </Button>
)}
<Button asChild> <Button asChild>
<Link to="/questions/new"> <Link to="/questions/new">
<Plus className="size-4" /> <Plus className="size-4" />
@ -481,6 +557,16 @@ export default function QuestionsPage() {
onSuccess={loadQuestions} onSuccess={loadQuestions}
/> />
{/* UGC 审核对话框 */}
<UgcReviewDialog
open={ugcReviewOpen}
onOpenChange={setUgcReviewOpen}
question={ugcReviewQuestion}
categories={categories}
onApprove={handleApproveUgc}
onReject={handleRejectUgc}
/>
{/* 批量操作确认 */} {/* 批量操作确认 */}
<AlertDialog open={batchConfirmOpen} onOpenChange={setBatchConfirmOpen}> <AlertDialog open={batchConfirmOpen} onOpenChange={setBatchConfirmOpen}>
<AlertDialogContent> <AlertDialogContent>

View File

@ -0,0 +1,514 @@
import { useCallback, useEffect, useState } from "react"
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
} from "@tanstack/react-table"
import { Search } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { fetchReports, dismissReport, resolveReport } from "@/lib/api/report-api"
import { REPORT_REASON_LABELS, REPORT_STATUS_LABELS } from "@/lib/constants"
import type { Report, ReportReason, ReportStatus } from "@/types/report"
const PAGE_SIZE = 20
const statusVariants: Record<ReportStatus, "default" | "secondary" | "outline" | "destructive"> = {
pending: "destructive",
reviewing: "default",
resolved: "outline",
dismissed: "secondary",
}
export default function ReportsPage() {
const [reports, setReports] = useState<Report[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<ReportStatus | "all">("all")
const [reasonFilter, setReasonFilter] = useState<ReportReason | "all">("all")
// 举报详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [selectedReport, setSelectedReport] = useState<Report | null>(null)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const loadReports = useCallback(async () => {
setLoading(true)
try {
const res = await fetchReports({
page,
limit: PAGE_SIZE,
search: search || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
reason: reasonFilter !== "all" ? reasonFilter : undefined,
})
setReports(res.data)
setTotal(res.pagination.total)
} catch {
setReports([])
} finally {
setLoading(false)
}
}, [page, search, statusFilter, reasonFilter])
useEffect(() => {
loadReports()
}, [loadReports])
function openDetail(report: Report) {
setSelectedReport(report)
setDetailOpen(true)
}
// 列定义
const columns: ColumnDef<Report>[] = [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{(row.getValue("id") as string).slice(0, 8)}
</span>
),
},
{
accessorKey: "reason",
header: "举报原因",
cell: ({ row }) => {
const reason = row.getValue("reason") as ReportReason
return (
<Badge variant="outline">
{REPORT_REASON_LABELS[reason]}
</Badge>
)
},
},
{
accessorKey: "description",
header: "描述",
cell: ({ row }) => {
const desc = row.getValue("description") as string
return (
<span className="line-clamp-2 max-w-xs" title={desc}>
{desc.length > 50 ? desc.slice(0, 50) + "..." : desc}
</span>
)
},
},
{
id: "target",
header: "举报对象",
cell: ({ row }) => {
const r = row.original
if (r.targetQuestionStem) {
return (
<div className="max-w-xs">
<span className="text-xs text-muted-foreground"></span>
<span className="line-clamp-1">{r.targetQuestionStem}</span>
</div>
)
}
if (r.targetUserNickname) {
return (
<div>
<span className="text-xs text-muted-foreground"></span>
<span>{r.targetUserNickname}</span>
</div>
)
}
return <span className="text-muted-foreground">-</span>
},
},
{
accessorKey: "reporterNickname",
header: "举报人",
cell: ({ row }) => {
const nickname = row.getValue("reporterNickname") as string
return (
<span className="text-sm">
{nickname || row.original.reporterId.slice(0, 8)}
</span>
)
},
},
{
accessorKey: "status",
header: "状态",
cell: ({ row }) => {
const status = row.getValue("status") as ReportStatus
return (
<Badge variant={statusVariants[status]}>
{REPORT_STATUS_LABELS[status]}
</Badge>
)
},
},
{
accessorKey: "createdAt",
header: "创建时间",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{new Date(row.getValue("createdAt") as string).toLocaleString("zh-CN")}
</span>
),
},
{
id: "actions",
header: "操作",
cell: ({ row }) => (
<Button
variant="ghost"
size="sm"
onClick={() => openDetail(row.original)}
>
</Button>
),
},
]
const table = useReactTable({
data: reports,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<div className="space-y-6">
{/* 页面头部 */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
</div>
{/* 筛选栏 */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative max-w-xs flex-1">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="搜索描述..."
className="pl-9"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
/>
</div>
<Select
value={statusFilter}
onValueChange={(val) => {
setStatusFilter(val as ReportStatus | "all")
setPage(1)
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(REPORT_STATUS_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={reasonFilter}
onValueChange={(val) => {
setReasonFilter(val as ReportReason | "all")
setPage(1)
}}
>
<SelectTrigger className="w-36">
<SelectValue placeholder="全部原因" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(REPORT_REASON_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 表格 */}
<div className="rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
...
</TableCell>
</TableRow>
) : reports.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{total} {page}/{totalPages}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
</Button>
</div>
</div>
)}
{/* 举报详情对话框 */}
{selectedReport && (
<ReportDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
report={selectedReport}
onRefresh={loadReports}
/>
)}
</div>
)
}
// 举报详情对话框组件
interface ReportDetailDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
report: Report
onRefresh: () => void
}
function ReportDetailDialog({
open,
onOpenChange,
report,
onRefresh,
}: ReportDetailDialogProps) {
const [note, setNote] = useState("")
const [submitting, setSubmitting] = useState(false)
async function handleDismiss() {
setSubmitting(true)
try {
await dismissReport(report.id, note || undefined)
setNote("")
onOpenChange(false)
onRefresh()
} finally {
setSubmitting(false)
}
}
async function handleResolve() {
setSubmitting(true)
try {
await resolveReport(report.id, note || undefined)
setNote("")
onOpenChange(false)
onRefresh()
} finally {
setSubmitting(false)
}
}
return (
<div className={open ? "fixed inset-0 z-50 flex items-center justify-center bg-black/50" : "hidden"}>
<div className="bg-background rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto m-4">
{/* 头部 */}
<div className="flex items-center justify-between p-6 border-b">
<div>
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground mt-1">
ID: {report.id}
</p>
</div>
<Button variant="ghost" size="icon" onClick={() => onOpenChange(false)}>
</Button>
</div>
{/* 内容 */}
<div className="p-6 space-y-4">
{/* 状态 */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Badge variant={statusVariants[report.status]}>
{REPORT_STATUS_LABELS[report.status]}
</Badge>
</div>
{/* 举报原因 */}
<div>
<span className="text-sm text-muted-foreground"></span>
<Badge variant="outline" className="ml-2">
{REPORT_REASON_LABELS[report.reason]}
</Badge>
</div>
{/* 描述 */}
<div>
<span className="text-sm text-muted-foreground"></span>
<p className="mt-1 text-sm">{report.description}</p>
</div>
{/* 举报对象 */}
<div className="border rounded-lg p-4 bg-muted/30">
<h3 className="font-medium mb-2"></h3>
{report.targetQuestionStem && (
<div>
<span className="text-xs text-muted-foreground"></span>
<p className="text-sm mt-1">{report.targetQuestionStem}</p>
</div>
)}
{report.targetUserNickname && (
<div>
<span className="text-xs text-muted-foreground"></span>
<span className="text-sm">{report.targetUserNickname}</span>
</div>
)}
</div>
{/* 举报人 */}
<div>
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm">
{report.reporterNickname || report.reporterId}
</span>
</div>
{/* 时间 */}
<div className="text-xs text-muted-foreground">
{new Date(report.createdAt).toLocaleString("zh-CN")}
</div>
{report.reviewedBy && (
<div className="text-xs text-muted-foreground">
{report.reviewedBy}
{report.reviewedAt && (
<>
{" · "}
{new Date(report.reviewedAt).toLocaleString("zh-CN")}
</>
)}
</div>
)}
{/* 处理备注 */}
{report.status === "pending" || report.status === "reviewing" ? (
<div className="pt-4 border-t">
<label className="text-sm text-muted-foreground"></label>
<textarea
className="w-full mt-2 min-h-20 px-3 py-2 border rounded-md text-sm"
placeholder="请输入处理备注(选填)..."
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
) : null}
</div>
{/* 底部操作 */}
{(report.status === "pending" || report.status === "reviewing") && (
<div className="flex gap-2 p-6 border-t justify-end">
<Button
variant="outline"
onClick={handleDismiss}
disabled={submitting}
>
{submitting ? "处理中..." : "驳回举报"}
</Button>
<Button
variant="destructive"
onClick={handleResolve}
disabled={submitting}
>
{submitting ? "处理中..." : "采纳并处理"}
</Button>
</div>
)}
</div>
</div>
)
}

View File

@ -1,10 +1,43 @@
import { useState } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { EventConfigTab } from "@/components/settings/EventConfigTab"
import { PushTemplateTab } from "@/components/settings/PushTemplateTab"
import { GeneralSettingsTab } from "@/components/settings/GeneralSettingsTab"
const tabs = [
{ value: "events", label: "活动配置" },
{ value: "push", label: "推送文案" },
{ value: "general", label: "通用设置" },
] as const
export default function SettingsPage() { export default function SettingsPage() {
const [activeTab, setActiveTab] = useState("events")
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed text-muted-foreground">
Phase 3 <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
</div> <TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="events" className="space-y-4">
<EventConfigTab />
</TabsContent>
<TabsContent value="push" className="space-y-4">
<PushTemplateTab />
</TabsContent>
<TabsContent value="general" className="space-y-4">
<GeneralSettingsTab />
</TabsContent>
</Tabs>
</div> </div>
) )
} }

View File

@ -1,5 +1,5 @@
import { create } from "zustand" import { create } from "zustand"
import { getStoredToken, setStoredToken, removeStoredToken } from "@/lib/auth" import { getStoredToken, setStoredToken, removeStoredToken, setCurrentAdminId } from "@/lib/auth"
import type { AdminUser } from "@/types/api" import type { AdminUser } from "@/types/api"
interface AuthState { interface AuthState {
@ -17,6 +17,7 @@ export const useAuthStore = create<AuthState>((set) => ({
login: (token, admin) => { login: (token, admin) => {
setStoredToken(token) setStoredToken(token)
setCurrentAdminId(admin.id)
set({ token, admin, isAuthenticated: true }) set({ token, admin, isAuthenticated: true })
}, },

25
src/types/admin.ts Normal file
View File

@ -0,0 +1,25 @@
export interface Admin {
id: string
username: string
role: AdminRole
createdAt: string
lastLoginAt?: string
}
export type AdminRole = "admin" | "moderator"
export interface AdminLoginForm {
username: string
password: string
}
export interface AdminSession {
admin: Admin
token: string
}
export interface CreateAdminForm {
username: string
password: string
role: AdminRole
}

View File

@ -16,7 +16,8 @@ export interface PaginatedResponse<T> {
export interface AdminUser { export interface AdminUser {
id: string id: string
role: "admin" username: string
role: "admin" | "moderator"
} }
export interface LoginRequest { export interface LoginRequest {

33
src/types/report.ts Normal file
View File

@ -0,0 +1,33 @@
export interface Report {
id: string
reporterId: string
reporterNickname?: string
targetQuestionId: string
targetQuestionStem?: string
targetUserId?: string
targetUserNickname?: string
reason: ReportReason
description: string
status: ReportStatus
reviewedBy?: string
reviewedAt?: string
createdAt: string
}
export type ReportReason =
| "inaccurate" // 内容错误
| "inappropriate" // 不当内容
| "copyright" // 版权问题
| "spam" // 垃圾信息
| "other"
export type ReportStatus =
| "pending" // 待处理
| "reviewing" // 处理中
| "resolved" // 已处理
| "dismissed" // 已驳回
export interface ReportFormData {
status: ReportStatus
note?: string
}

56
src/types/settings.ts Normal file
View File

@ -0,0 +1,56 @@
// 活动配置
export interface EventConfig {
id: string
name: string
description: string
startDate: string // ISO date string
endDate: string // ISO date string
xpMultiplier: number
status: EventStatus
createdAt: string
updatedAt: string
}
export type EventStatus = "draft" | "active" | "ended"
// 推送模板
export interface PushTemplate {
id: string
name: string
title: string
body: string
triggerType: PushTriggerType
variables: string[] // 可用变量列表
createdAt: string
updatedAt: string
}
export type PushTriggerType = "streak" | "achievement" | "event" | "manual"
// 通用设置
export interface AppSetting {
id: string
key: string
value: string
category: SettingCategory
description?: string
updatedAt: string
}
export type SettingCategory = "event" | "push" | "general"
// 表单数据类型
export interface EventFormData {
name: string
description: string
startDate: string
endDate: string
xpMultiplier: number
}
export interface PushTemplateFormData {
name: string
title: string
body: string
triggerType: PushTriggerType
}