From 0a31f8634e37aac6cb33b8406118e89c64ecde9c Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Wed, 8 Apr 2026 15:38:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Phase=203=20?= =?UTF-8?q?=E2=80=94=20UGC=20=E5=AE=A1=E6=A0=B8=E3=80=81=E4=B8=BE=E6=8A=A5?= =?UTF-8?q?=E5=A4=84=E7=90=86=E3=80=81=E8=BF=90=E8=90=A5=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E3=80=81=E5=A4=9A=E7=AE=A1=E7=90=86=E5=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/rules/typescript/coding-style.md | 199 +++++++ .claude/rules/typescript/hooks.md | 22 + .claude/rules/typescript/patterns.md | 52 ++ .claude/rules/typescript/security.md | 28 + .claude/rules/typescript/testing.md | 18 + .claude/settings.json | 5 + .claude/settings.local.json | 20 + src/App.tsx | 4 + src/components/layout/Sidebar.tsx | 6 + src/components/question/UgcReviewDialog.tsx | 233 ++++++++ src/components/question/columns.tsx | 21 +- src/components/settings/EventConfigTab.tsx | 340 ++++++++++++ .../settings/GeneralSettingsTab.tsx | 266 +++++++++ src/components/settings/PushTemplateTab.tsx | 352 ++++++++++++ src/components/ui/switch.tsx | 33 ++ src/lib/api/admin-api.ts | 49 ++ src/lib/api/question-api.ts | 2 + src/lib/api/report-api.ts | 55 ++ src/lib/api/settings-api.ts | 93 ++++ src/lib/auth.ts | 11 + src/lib/constants.ts | 47 ++ src/routes/admins/index.tsx | 389 +++++++++++++ src/routes/login.tsx | 70 ++- src/routes/questions/index.tsx | 100 +++- src/routes/reports/index.tsx | 514 ++++++++++++++++++ src/routes/settings/index.tsx | 39 +- src/stores/auth-store.ts | 3 +- src/types/admin.ts | 25 + src/types/api.ts | 3 +- src/types/report.ts | 33 ++ src/types/settings.ts | 56 ++ 31 files changed, 3059 insertions(+), 29 deletions(-) create mode 100644 .claude/rules/typescript/coding-style.md create mode 100644 .claude/rules/typescript/hooks.md create mode 100644 .claude/rules/typescript/patterns.md create mode 100644 .claude/rules/typescript/security.md create mode 100644 .claude/rules/typescript/testing.md create mode 100644 .claude/settings.json create mode 100644 .claude/settings.local.json create mode 100644 src/components/question/UgcReviewDialog.tsx create mode 100644 src/components/settings/EventConfigTab.tsx create mode 100644 src/components/settings/GeneralSettingsTab.tsx create mode 100644 src/components/settings/PushTemplateTab.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/lib/api/admin-api.ts create mode 100644 src/lib/api/report-api.ts create mode 100644 src/lib/api/settings-api.ts create mode 100644 src/routes/admins/index.tsx create mode 100644 src/routes/reports/index.tsx create mode 100644 src/types/admin.ts create mode 100644 src/types/report.ts create mode 100644 src/types/settings.ts diff --git a/.claude/rules/typescript/coding-style.md b/.claude/rules/typescript/coding-style.md new file mode 100644 index 0000000..090c0a1 --- /dev/null +++ b/.claude/rules/typescript/coding-style.md @@ -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 +} +``` + +### 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, 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 + +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 { + 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 + +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 diff --git a/.claude/rules/typescript/hooks.md b/.claude/rules/typescript/hooks.md new file mode 100644 index 0000000..cd4754b --- /dev/null +++ b/.claude/rules/typescript/hooks.md @@ -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 diff --git a/.claude/rules/typescript/patterns.md b/.claude/rules/typescript/patterns.md new file mode 100644 index 0000000..d50729d --- /dev/null +++ b/.claude/rules/typescript/patterns.md @@ -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 { + success: boolean + data?: T + error?: string + meta?: { + total: number + page: number + limit: number + } +} +``` + +## Custom Hooks Pattern + +```typescript +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => setDebouncedValue(value), delay) + return () => clearTimeout(handler) + }, [value, delay]) + + return debouncedValue +} +``` + +## Repository Pattern + +```typescript +interface Repository { + findAll(filters?: Filters): Promise + findById(id: string): Promise + create(data: CreateDto): Promise + update(id: string, data: UpdateDto): Promise + delete(id: string): Promise +} +``` diff --git a/.claude/rules/typescript/security.md b/.claude/rules/typescript/security.md new file mode 100644 index 0000000..98ba400 --- /dev/null +++ b/.claude/rules/typescript/security.md @@ -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 diff --git a/.claude/rules/typescript/testing.md b/.claude/rules/typescript/testing.md new file mode 100644 index 0000000..6f2f402 --- /dev/null +++ b/.claude/rules/typescript/testing.md @@ -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 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0cbf4db --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "typescript-lsp@claude-plugins-official": true + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4f9c81f --- /dev/null +++ b/.claude/settings.local.json @@ -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:*)" + ] + } +} diff --git a/src/App.tsx b/src/App.tsx index ecc911c..a54d269 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,8 @@ import SkillTreePage from "@/routes/skill-tree" import UsersPage from "@/routes/users" import UserDetailPage from "@/routes/users/$id" import FeedbackPage from "@/routes/feedback" +import ReportsPage from "@/routes/reports" +import AdminsPage from "@/routes/admins" import SettingsPage from "@/routes/settings" const router = createBrowserRouter([ @@ -33,6 +35,8 @@ const router = createBrowserRouter([ { path: "categories", Component: CategoriesPage }, { path: "skill-tree", Component: SkillTreePage }, { path: "feedback", Component: FeedbackPage }, + { path: "reports", Component: ReportsPage }, + { path: "admins", Component: AdminsPage }, { path: "users", children: [ diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 91f73fb..04b922e 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -8,6 +8,9 @@ import { MessageSquare, Settings, LogOut, + FileCheck, + AlertCircle, + Shield, } from "lucide-react" import { cn } from "@/lib/utils" import { useAuth } from "@/hooks/use-auth" @@ -15,10 +18,13 @@ import { useAuth } from "@/hooks/use-auth" const navItems = [ { to: "/", label: "数据看板", icon: LayoutDashboard, end: true }, { to: "/questions", label: "题库管理", icon: BookOpen }, + { to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck }, { to: "/categories", label: "分类管理", icon: FolderOpen }, { to: "/skill-tree", label: "技能树", icon: TreePine }, { to: "/users", label: "用户管理", icon: Users }, { to: "/feedback", label: "用户反馈", icon: MessageSquare }, + { to: "/reports", label: "举报处理", icon: AlertCircle }, + { to: "/admins", label: "管理员", icon: Shield }, { to: "/settings", label: "系统设置", icon: Settings }, ] diff --git a/src/components/question/UgcReviewDialog.tsx b/src/components/question/UgcReviewDialog.tsx new file mode 100644 index 0000000..2df9a07 --- /dev/null +++ b/src/components/question/UgcReviewDialog.tsx @@ -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 + onReject: (note: string) => Promise +} + +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 ( + + + + UGC 题目审核 + + 请仔细检查用户投稿的题目内容,审核通过后题目将发布到题库。 + + + +
+ {/* 题目基本信息 */} +
+
+ +

{category?.name ?? question.categoryId}

+
+
+ +

{DIFFICULTY_LABELS[question.difficulty]}

+
+
+ +

{QUESTION_SOURCE_LABELS[question.source]}

+
+
+ + {QUESTION_STATUSES[question.status]} +
+
+ + + + {/* 题干 */} +
+ +

{question.stem}

+
+ + {/* 正确答案 */} +
+ +

{question.correctAnswer}

+
+ + {/* 干扰项 */} +
+ +
    + {question.distractors.map((d, i) => ( +
  • + · {d} +
  • + ))} +
+
+ + {/* 知识卡 */} +
+ +

{question.knowledgeCardBasic}

+
+ + {question.knowledgeCardDeep && ( +
+ +

+ {question.knowledgeCardDeep} +

+
+ )} + + + + {/* 统计信息 */} +
+
+ +

{question.stats.timesAnswered}

+
+
+ +

{(question.stats.correctRate * 100).toFixed(0)}%

+
+
+ +

{(question.stats.avgTimeMs / 1000).toFixed(1)}秒

+
+
+ + + + {/* 审核备注 */} +
+ +