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:
parent
f73246b523
commit
0a31f8634e
199
.claude/rules/typescript/coding-style.md
Normal file
199
.claude/rules/typescript/coding-style.md
Normal 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
|
||||||
22
.claude/rules/typescript/hooks.md
Normal file
22
.claude/rules/typescript/hooks.md
Normal 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
|
||||||
52
.claude/rules/typescript/patterns.md
Normal file
52
.claude/rules/typescript/patterns.md
Normal 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>
|
||||||
|
}
|
||||||
|
```
|
||||||
28
.claude/rules/typescript/security.md
Normal file
28
.claude/rules/typescript/security.md
Normal 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
|
||||||
18
.claude/rules/typescript/testing.md
Normal file
18
.claude/rules/typescript/testing.md
Normal 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
5
.claude/settings.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"typescript-lsp@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
20
.claude/settings.local.json
Normal file
20
.claude/settings.local.json
Normal 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:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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: [
|
||||||
|
|||||||
@ -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 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
233
src/components/question/UgcReviewDialog.tsx
Normal file
233
src/components/question/UgcReviewDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 />
|
||||||
|
|||||||
340
src/components/settings/EventConfigTab.tsx
Normal file
340
src/components/settings/EventConfigTab.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
266
src/components/settings/GeneralSettingsTab.tsx
Normal file
266
src/components/settings/GeneralSettingsTab.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
352
src/components/settings/PushTemplateTab.tsx
Normal file
352
src/components/settings/PushTemplateTab.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/components/ui/switch.tsx
Normal file
33
src/components/ui/switch.tsx
Normal 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
49
src/lib/api/admin-api.ts
Normal 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>>()
|
||||||
|
}
|
||||||
@ -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
55
src/lib/api/report-api.ts
Normal 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>>()
|
||||||
|
}
|
||||||
93
src/lib/api/settings-api.ts
Normal file
93
src/lib/api/settings-api.ts
Normal 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>>()
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
389
src/routes/admins/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
<h1 className="text-2xl font-bold">题库管理</h1>
|
<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">
|
<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>
|
||||||
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
{sourceTab === "system" && (
|
||||||
批量导入
|
<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>
|
||||||
|
|||||||
514
src/routes/reports/index.tsx
Normal file
514
src/routes/reports/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
25
src/types/admin.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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
33
src/types/report.ts
Normal 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
56
src/types/settings.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user