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

View File

@ -8,6 +8,9 @@ import {
MessageSquare,
Settings,
LogOut,
FileCheck,
AlertCircle,
Shield,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useAuth } from "@/hooks/use-auth"
@ -15,10 +18,13 @@ import { useAuth } from "@/hooks/use-auth"
const navItems = [
{ to: "/", label: "数据看板", icon: LayoutDashboard, end: true },
{ to: "/questions", label: "题库管理", icon: BookOpen },
{ to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck },
{ to: "/categories", label: "分类管理", icon: FolderOpen },
{ to: "/skill-tree", label: "技能树", icon: TreePine },
{ to: "/users", label: "用户管理", icon: Users },
{ to: "/feedback", label: "用户反馈", icon: MessageSquare },
{ to: "/reports", label: "举报处理", icon: AlertCircle },
{ to: "/admins", label: "管理员", icon: Shield },
{ 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,
} from "@/components/ui/dropdown-menu"
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 { Category } from "@/types/category"
@ -22,6 +22,7 @@ interface ColumnContext {
categories: Category[]
onDelete: (question: Question) => void
onStatusChange: (question: Question, status: QuestionStatus) => void
onReview?: (question: Question) => void
}
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",
header: "难度",
@ -160,6 +173,12 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
</DropdownMenuItem>
{question.source === "ugc" && ctx.onReview && (
<DropdownMenuItem onClick={() => ctx.onReview!(question)}>
</DropdownMenuItem>
)}
{transitions.length > 0 && (
<>
<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
categoryId?: string
difficulty?: Difficulty
source?: "system" | "ugc"
}
export async function fetchQuestions(
@ -21,6 +22,7 @@ export async function fetchQuestions(
if (params.status) searchParams.set("status", params.status)
if (params.categoryId) searchParams.set("categoryId", params.categoryId)
if (params.difficulty) searchParams.set("difficulty", String(params.difficulty))
if (params.source) searchParams.set("source", params.source)
return apiClient
.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"
const CURRENT_ADMIN_ID_KEY = "duoqi_admin_current_id"
export function getStoredToken(): string | null {
return localStorage.getItem(AUTH_STORAGE_KEY)
}
@ -10,4 +12,13 @@ export function setStoredToken(token: string): void {
export function removeStoredToken(): void {
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 { QuestionStatus } from "@/types/question"
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 = {
draft: "草稿",
@ -61,3 +64,47 @@ export const TIER_DESCRIPTIONS: Record<UserTier, string> = {
pro: "无广告 + 无限红心 + 深度知识卡 + 连胜自动修复",
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 { z } from "zod/v4"
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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
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({
token: z.string().min(1, "请输入 Admin Token"),
username: z.string().min(1, "请输入用户名"),
password: z.string().min(1, "请输入密码"),
})
type LoginForm = z.infer<typeof loginSchema>
@ -21,6 +23,7 @@ export default function LoginPage() {
const navigate = useNavigate()
const { login } = useAuth()
const [error, setError] = useState("")
const [showPassword, setShowPassword] = useState(false)
const {
register,
@ -28,19 +31,29 @@ export default function LoginPage() {
formState: { errors, isSubmitting },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",
},
})
async function onSubmit(data: LoginForm) {
setError("")
try {
const response = await apiClient
.post("admin/auth/login", { json: { token: data.token } })
.json<LoginResponse>()
const response = await loginAdmin(data)
const session: AdminSession = response.data
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("/")
} catch {
setError("登录失败,请检查 Token 是否正确")
setError("登录失败,请检查用户名和密码")
}
}
@ -49,20 +62,45 @@ export default function LoginPage() {
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-xl"></CardTitle>
<CardDescription> Admin Token </CardDescription>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="token">Admin Token</Label>
<Label htmlFor="username"></Label>
<Input
id="token"
type="password"
placeholder="请输入 Token"
{...register("token")}
id="username"
placeholder="请输入用户名"
autoComplete="username"
{...register("username")}
/>
{errors.token && (
<p className="text-sm text-destructive">{errors.token.message}</p>
{errors.username && (
<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>

View File

@ -1,12 +1,12 @@
import { useCallback, useEffect, useState } from "react"
import { Link } from "react-router"
import { Link, useSearchParams } from "react-router"
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
} 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 { Input } from "@/components/ui/input"
import {
@ -16,6 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Table,
TableBody,
@ -37,6 +38,7 @@ import {
import { getColumns } from "@/components/question/columns"
import { StatusTransitionDialog } from "@/components/question/StatusTransitionDialog"
import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDialog"
import { UgcReviewDialog } from "@/components/question/UgcReviewDialog"
import { Checkbox } from "@/components/ui/checkbox"
import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchOperateQuestions } from "@/lib/api/question-api"
import { fetchCategories } from "@/lib/api/category-api"
@ -47,7 +49,16 @@ import type { Category } from "@/types/category"
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() {
const [searchParams, setSearchParams] = useSearchParams()
const [questions, setQuestions] = useState<Question[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
@ -58,6 +69,22 @@ export default function QuestionsPage() {
const [categoryFilter, setCategoryFilter] = 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 [deletingQuestion, setDeletingQuestion] = useState<Question | null>(null)
@ -70,6 +97,10 @@ export default function QuestionsPage() {
// 批量导入对话框
const [importOpen, setImportOpen] = useState(false)
// UGC 审核对话框
const [ugcReviewOpen, setUgcReviewOpen] = useState(false)
const [ugcReviewQuestion, setUgcReviewQuestion] = useState<Question | null>(null)
// 批量操作
const [batchConfirmOpen, setBatchConfirmOpen] = useState(false)
const [batchAction, setBatchAction] = useState<"publish" | "archive" | "delete">("delete")
@ -95,6 +126,7 @@ export default function QuestionsPage() {
difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty)
: undefined,
source: sourceTab !== "all" ? sourceTab : undefined,
})
setQuestions(res.data)
setTotal(res.pagination.total)
@ -103,7 +135,7 @@ export default function QuestionsPage() {
} finally {
setLoading(false)
}
}, [page, search, statusFilter, categoryFilter, difficultyFilter])
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab])
useEffect(() => {
loadQuestions()
@ -157,10 +189,35 @@ export default function QuestionsPage() {
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({
categories,
onDelete: openDelete,
onStatusChange: handleStatusChange,
onReview: sourceTab === "ugc" ? openUgcReview : undefined,
})
// 选择列 + 数据列
@ -224,15 +281,34 @@ export default function QuestionsPage() {
<div className="space-y-6">
{/* 页面头部 */}
<div className="flex items-center justify-between">
<div className="space-y-2">
<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">
{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}>
<Download className="mr-1 size-4" />
{exporting ? "导出中..." : "导出 CSV"}
</Button>
{sourceTab === "system" && (
<Button variant="outline" onClick={() => setImportOpen(true)}>
</Button>
)}
<Button asChild>
<Link to="/questions/new">
<Plus className="size-4" />
@ -481,6 +557,16 @@ export default function QuestionsPage() {
onSuccess={loadQuestions}
/>
{/* UGC 审核对话框 */}
<UgcReviewDialog
open={ugcReviewOpen}
onOpenChange={setUgcReviewOpen}
question={ugcReviewQuestion}
categories={categories}
onApprove={handleApproveUgc}
onReject={handleRejectUgc}
/>
{/* 批量操作确认 */}
<AlertDialog open={batchConfirmOpen} onOpenChange={setBatchConfirmOpen}>
<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() {
const [activeTab, setActiveTab] = useState("events")
return (
<div className="space-y-6">
<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
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<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>
)
}

View File

@ -1,5 +1,5 @@
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"
interface AuthState {
@ -17,6 +17,7 @@ export const useAuthStore = create<AuthState>((set) => ({
login: (token, admin) => {
setStoredToken(token)
setCurrentAdminId(admin.id)
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 {
id: string
role: "admin"
username: string
role: "admin" | "moderator"
}
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
}