Compare commits

...

10 Commits

Author SHA1 Message Date
37b936ec52 feat: 对接题目查询接口,统一数据模型与 API 规范
- Question.stem 从 string 改为 { text: string } 对象,匹配 API 多语言题干格式
- 新增 contentType 字段(text/image/video/audio)
- knowledgeCard 从扁平字段重组为嵌套对象 { summary, deepDive?, sourceRef? }
- source/stats 改为可选字段,UI 添加空值回退
- 查询参数对齐:search→keyword, sort→sortBy, order→sortOrder
- QuestionForm 接入 createQuestion/updateQuestion API
- KnowledgeCardFields 组件重命名 props 匹配新结构
2026-04-12 00:34:09 +08:00
a822e91c63 feat: 对接题目批量导入接口(JSON + CSV)
- 新增 ImportQuestionItem / ImportSuccessResult / ImportValidationError 类型,匹配 API 规范
- importQuestions 改用 API 规范的 stem: { text }、contentType、嵌套 knowledgeCard 结构
- 新增 importQuestionsCsv 函数,POST text/plain 到 /admin/questions/import-csv
- 重写 ImportQuestionsDialog:JSON/CSV 双模式 Tab 切换、预览、校验错误详情展示
2026-04-11 23:40:14 +08:00
4cb26daa02 feat: 对接题目批量发布/归档/删除接口
替换原单一 batch 端点为 batch-publish、batch-archive、batch-delete 三个独立端点,
BatchResult 类型对齐 API 规范(total/succeeded/failed),新增 BatchResultDialog
展示批量操作结果及失败项详情。
2026-04-11 22:36:15 +08:00
d1af1dbe11 feat: 对接题目状态转换接口,补全转换路径和错误处理
- 补全 draft→archived、reviewing→archived 转换路径,与 API 规范对齐
- 添加 draft→archived、reviewing→archived 状态流转描述文案
- 为 confirmStatusChange、handleApproveUgc、handleRejectUgc 添加 try/catch 错误处理
2026-04-11 21:56:59 +08:00
8e3d4ed190 refactor: 对接 duoqi-api 管理员管理接口规范
- 精简角色类型:移除 moderator,仅保留 super_admin 和 admin
- Admin 数据模型补全 isActive、updatedAt 字段
- 创建/重置密码改为展示服务端生成的 plainPassword(含复制按钮)
- 新增编辑管理员对话框(用户名、角色、启用/停用状态)
- fetchAdmins 支持分页和筛选参数
- loginWithToken 适配向后兼容的 { authenticated } 响应格式
- 添加内联成功/错误消息提示
2026-04-11 18:53:37 +08:00
66fc078b3c refactor: 对接 duoqi-api 管理员登录规范
- ApiResponse 改为标准 { success, data, error } 格式
- 登录响应使用 accessToken/refreshToken 字段
- AdminRole 新增 super_admin 角色
- auth-store 支持 refreshToken 存储
- 所有 API 调用处理 data 可能为 null 的情况
2026-04-11 17:46:00 +08:00
2c2fc952f9 refactor: 对接 duoqi-api 文档规范
- API 路径前缀改为 /v1/admin
- 分类管理改用服务端分页(page/limit),移除未定义的 search/status 筛选
- 知识卡字段重命名:basic→summary、deep→deepDive
- 各页面移除不必要的 limit 参数
2026-04-11 15:10:44 +08:00
b6dc6848af docs: 更新 CLAUDE.md — 知识卡页面和排序功能补全
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 18:42:44 +08:00
1fc27207e0 feat: 完善知识卡独立页面和题目列表排序
- 新增 /knowledge-cards 独立路由和列表页面
- 知识卡支持搜索、完成度筛选、详情查看和快速编辑
- 题目列表添加排序控件(创建时间/更新时间/难度 + 升降序)
- API 层支持 sort 和 order 参数

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 18:41:45 +08:00
87a1f39d51 docs: 更新 CLAUDE.md 和 dev-spec.md — Phase 3 完成
- CLAUDE.md: 更新 Current Status、Architecture 树、Auth flow 说明
- dev-spec.md: 更新状态为 Phase 3 已完成

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 18:23:58 +08:00
33 changed files with 1915 additions and 518 deletions

View File

@ -14,7 +14,8 @@
"Bash(bunx:*)", "Bash(bunx:*)",
"Bash(bun run:*)", "Bash(bun run:*)",
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)" "Bash(git commit:*)",
"mcp__plugin_ecc_sequential-thinking__sequentialthinking"
] ]
} }
} }

View File

@ -5,7 +5,7 @@
## Current Status ## Current Status
**Phase 2 done.** 用户详情页 + 反馈管理 + 订阅管理 + CSV 导出 已完成。Phase 1c 数据看板真实数据仍延后。Next: Phase 3 — UGC 审核. **Phase 3 done + 补全完毕。** UGC 审核 + 举报处理 + 运营配置 + 多管理员支持 已完成。知识卡独立页面和题目列表排序已补全。Phase 1c 数据看板真实数据仍延后。所有计划内功能开发完毕,后续按需迭代。
Development follows the phased roadmap in [dev-spec.md](./dev-spec.md) §九. Development follows the phased roadmap in [dev-spec.md](./dev-spec.md) §九.
@ -33,31 +33,37 @@ src/
├── App.tsx # Root (router + layout) ├── App.tsx # Root (router + layout)
├── styles/globals.css # Tailwind v4 + shadcn/ui CSS variables ├── styles/globals.css # Tailwind v4 + shadcn/ui CSS variables
├── routes/ # Pages (login, dashboard, questions/*, categories/*, ...) ├── routes/ # Pages (login, dashboard, questions/*, categories/*, ...)
│ ├── knowledge-cards/ # Knowledge card management (知识卡管理)
│ ├── reports/ # Report handling (举报处理)
│ ├── admins/ # Admin management (管理员管理)
│ └── settings/ # System settings (运营配置)
├── components/ ├── components/
│ ├── ui/ # shadcn/ui primitives │ ├── ui/ # shadcn/ui primitives
│ ├── layout/ # Sidebar, Header, AdminLayout │ ├── layout/ # Sidebar, Header, AdminLayout
│ ├── charts/ # StatsCard, chart wrappers │ ├── charts/ # StatsCard, chart wrappers
│ ├── category/ # Category CRUD (columns, dialogs) │ ├── category/ # Category CRUD (columns, dialogs)
│ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor, KnowledgeCardFields, StatusTransitionDialog, ImportQuestionsDialog) │ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor, KnowledgeCardFields, StatusTransitionDialog, ImportQuestionsDialog, UgcReviewDialog)
│ ├── skill-tree/ # Skill Tree chapter CRUD (columns, form dialog, delete dialog) │ ├── skill-tree/ # Skill Tree chapter CRUD (columns, form dialog, delete dialog)
│ ├── user/ # User CRUD (columns, UserProfileCard, GameStatsGrid, AnswerHistoryTable, ChapterProgressList, TierChangeDialog) │ ├── user/ # User CRUD (columns, UserProfileCard, GameStatsGrid, AnswerHistoryTable, ChapterProgressList, TierChangeDialog)
│ ├── feedback/ # Feedback management (columns, FeedbackDetailDialog) │ ├── feedback/ # Feedback management (columns, FeedbackDetailDialog)
│ └── settings/ # Settings tabs (EventConfigTab, PushTemplateTab, GeneralSettingsTab)
├── lib/ ├── lib/
│ ├── api-client.ts # HTTP client for /admin/* endpoints │ ├── api-client.ts # HTTP client for /admin/* endpoints
│ ├── auth.ts # Admin JWT token management │ ├── auth.ts # Admin JWT token management + current admin ID
│ ├── utils.ts # Utility functions │ ├── utils.ts # Utility functions
│ ├── csv-export.ts # Generic CSV export utility (BOM-compatible) │ ├── csv-export.ts # Generic CSV export utility (BOM-compatible)
│ └── constants.ts # Status enums, difficulty levels, feedback/tier labels │ ├── constants.ts # Status enums, difficulty levels, feedback/tier/report/event/push/admin labels
│ └── api/ # API modules (question-api, category-api, report-api, settings-api, admin-api)
├── hooks/ # useAuth ├── hooks/ # useAuth
├── stores/ # Zustand stores (auth-store) ├── stores/ # Zustand stores (auth-store)
└── types/ # question, user, user-detail, feedback, category, api types └── types/ # question, user, user-detail, feedback, category, report, settings, admin, api types
``` ```
## Key Patterns ## Key Patterns
- **Routing**: React Router v7 library mode (`createBrowserRouter` + `RouterProvider`). Routes defined in `App.tsx`. Root layout (`routes/__root.tsx`) handles auth guard — redirects to `/login` when not authenticated. - **Routing**: React Router v7 library mode (`createBrowserRouter` + `RouterProvider`). Routes defined in `App.tsx`. Root layout (`routes/__root.tsx`) handles auth guard — redirects to `/login` when not authenticated.
- **Development order**: Follow [dev-spec.md](./dev-spec.md) §九 (Phase roadmap). - **Development order**: Follow [dev-spec.md](./dev-spec.md) §九 (Phase roadmap).
- **Auth flow**: Login page → POST `/admin/auth/login` with token → receive admin JWT → store in auth-store → attach as `Authorization: Bearer <jwt>` on all subsequent requests - **Auth flow**: Login page supports Token and username/password login. Token: POST `/admin/auth/login` → receive JWT. Password: POST `/admin/auth/login` with credentials → receive JWT. Both modes fall back to offline mode when backend is unavailable (stores a `offline_` prefixed token locally). JWT stored in auth-store → attached as `Authorization: Bearer <jwt>` on all subsequent requests.
- **API client**: All admin API calls go through `lib/api-client.ts` (ky v2). Uses `baseUrl` + `prefix: "/admin"`. Auto-attaches auth header. 401 responses trigger logout + redirect to `/login`. - **API client**: All admin API calls go through `lib/api-client.ts` (ky v2). Uses `baseUrl` + `prefix: "/admin"`. Auto-attaches auth header. 401 responses trigger logout + redirect to `/login`.
- **Data tables**: TanStack Table v8 headless + shadcn/ui styled components in `components/data-table/` - **Data tables**: TanStack Table v8 headless + shadcn/ui styled components in `components/data-table/`
- **Forms**: React Hook Form + Zod validation for all admin forms - **Forms**: React Hook Form + Zod validation for all admin forms

View File

@ -414,4 +414,4 @@ VITE_API_BASE_URL=http://localhost:3000
--- ---
*创建日期2026-04-06* *创建日期2026-04-06*
*状态Phase 2 已完成Phase 3 待启动* *状态Phase 3 已完成UGC 审核、举报处理、运营配置、多管理员)。所有计划内功能开发完毕。*

View File

@ -6,6 +6,7 @@ import QuestionsPage from "@/routes/questions"
import NewQuestionPage from "@/routes/questions/new" import NewQuestionPage from "@/routes/questions/new"
import EditQuestionPage from "@/routes/questions/$id.edit" import EditQuestionPage from "@/routes/questions/$id.edit"
import CategoriesPage from "@/routes/categories" import CategoriesPage from "@/routes/categories"
import KnowledgeCardsPage from "@/routes/knowledge-cards"
import SkillTreePage from "@/routes/skill-tree" 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"
@ -33,6 +34,7 @@ const router = createBrowserRouter([
], ],
}, },
{ path: "categories", Component: CategoriesPage }, { path: "categories", Component: CategoriesPage },
{ path: "knowledge-cards", Component: KnowledgeCardsPage },
{ path: "skill-tree", Component: SkillTreePage }, { path: "skill-tree", Component: SkillTreePage },
{ path: "feedback", Component: FeedbackPage }, { path: "feedback", Component: FeedbackPage },
{ path: "reports", Component: ReportsPage }, { path: "reports", Component: ReportsPage },

View File

@ -11,6 +11,7 @@ import {
FileCheck, FileCheck,
AlertCircle, AlertCircle,
Shield, Shield,
BookMarked,
} 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"
@ -20,6 +21,7 @@ const navItems = [
{ to: "/questions", label: "题库管理", icon: BookOpen }, { to: "/questions", label: "题库管理", icon: BookOpen },
{ to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck }, { to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck },
{ to: "/categories", label: "分类管理", icon: FolderOpen }, { to: "/categories", label: "分类管理", icon: FolderOpen },
{ to: "/knowledge-cards", label: "知识卡", icon: BookMarked },
{ 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 },

View File

@ -0,0 +1,86 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { CheckCircle2, XCircle } from "lucide-react"
import type { BatchResult } from "@/lib/api/question-api"
interface BatchResultDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
result: BatchResult | null
action: "publish" | "archive" | "delete"
}
const ACTION_LABELS = {
publish: "发布",
archive: "归档",
delete: "删除",
} as const
export function BatchResultDialog({
open,
onOpenChange,
result,
action,
}: BatchResultDialogProps) {
if (!result) return null
const hasFailures = result.failed.length > 0
const allSucceeded = result.succeeded === result.total
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{allSucceeded ? (
<CheckCircle2 className="size-5 text-green-600" />
) : (
<XCircle className="size-5 text-yellow-600" />
)}
{ACTION_LABELS[action]}
</DialogTitle>
<DialogDescription>
{result.total} {result.succeeded}
{hasFailures ? `,失败 ${result.failed.length}` : ""}
</DialogDescription>
</DialogHeader>
{hasFailures && (
<div className="max-h-60 overflow-y-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium"> ID</th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{result.failed.map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-1.5 font-mono text-xs">
{item.id.slice(0, 8)}...
</td>
<td className="px-3 py-1.5 text-muted-foreground">
{item.reason}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<DialogFooter>
<Button onClick={() => onOpenChange(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -10,23 +10,53 @@ import {
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { importQuestions } from "@/lib/api/question-api" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import type { QuestionFormData } from "@/types/question" import {
importQuestions,
importQuestionsCsv,
} from "@/lib/api/question-api"
import type {
ImportQuestionItem,
ImportSuccessResult,
ImportValidationError,
} from "@/types/question"
const importItemSchema = z.object({ // ── JSON import schema (matches POST /admin/questions/import) ──
stem: z.string().min(1, "题干不能为空"),
correctAnswer: z.string().min(1, "正确答案不能为空"), const knowledgeCardSchema = z.object({
distractors: z.array(z.string().min(1)).min(4, "至少 4 个干扰项").max(6), summary: z.string().min(1, "知识点摘要不能为空"),
categoryId: z.string().min(1, "请选择分类"), deepDive: z.string().optional(),
difficulty: z.number().min(1).max(5), sourceRef: z.string().optional(),
status: z.enum(["draft", "reviewing", "published", "archived"]).optional(),
knowledgeCardBasic: z.string().min(1, "请填写基础知识卡").max(100).optional(),
knowledgeCardDeep: z.string().max(300).optional(),
sourceRef: z.string().max(500).optional(),
}) })
const importArraySchema = z.array(importItemSchema).min(1, "至少导入 1 道题目") const importItemSchema = z.object({
stem: z.object({ text: z.string().min(1, "题干不能为空") }),
contentType: z.enum(["text", "image", "video", "audio"]).optional(),
correctAnswer: z.string().min(1, "正确答案不能为空"),
distractors: z
.array(z.string().min(1))
.min(2, "至少 2 个干扰项")
.max(6, "最多 6 个干扰项"),
categoryId: z.string().min(1, "请填写分类 ID"),
difficulty: z.number().min(1).max(5).optional(),
knowledgeCard: knowledgeCardSchema.optional(),
})
const importArraySchema = z
.array(importItemSchema)
.min(1, "至少导入 1 道题目")
.max(200, "单次最多导入 200 道题目")
// ── CSV constants ──
const CSV_HEADER =
"categoryId,contentType,difficulty,stemText,correctAnswer,distractor1,distractor2,distractor3,distractor4,distractor5,cardSummary,cardDeepDive,cardSourceRef"
const CSV_HEADER_COLS = CSV_HEADER.split(",")
const CSV_COLUMN_COUNT = CSV_HEADER_COLS.length
// ── State types ──
type ImportMode = "json" | "csv"
type ImportStep = "input" | "preview" | "result" type ImportStep = "input" | "preview" | "result"
interface ImportQuestionsDialogProps { interface ImportQuestionsDialogProps {
@ -40,25 +70,39 @@ export function ImportQuestionsDialog({
onOpenChange, onOpenChange,
onSuccess, onSuccess,
}: ImportQuestionsDialogProps) { }: ImportQuestionsDialogProps) {
const fileInputRef = useRef<HTMLInputElement>(null) const jsonFileRef = useRef<HTMLInputElement>(null)
const csvFileRef = useRef<HTMLInputElement>(null)
const [mode, setMode] = useState<ImportMode>("json")
const [step, setStep] = useState<ImportStep>("input") const [step, setStep] = useState<ImportStep>("input")
const [rawJson, setRawJson] = useState("")
const [parseError, setParseError] = useState<string | null>(null) const [parseError, setParseError] = useState<string | null>(null)
const [parsedQuestions, setParsedQuestions] = useState<QuestionFormData[]>([])
// JSON state
const [rawJson, setRawJson] = useState("")
const [parsedItems, setParsedItems] = useState<ImportQuestionItem[]>([])
// CSV state
const [rawCsv, setRawCsv] = useState("")
const [csvFileName, setCsvFileName] = useState("")
// Import state
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [result, setResult] = useState<{ const [result, setResult] = useState<ImportSuccessResult | null>(null)
imported: number const [validationErrors, setValidationErrors] = useState<
failed: number ImportValidationError[]
errors?: { index: number; error: string }[] >([])
} | null>(null)
function reset() { function reset() {
setStep("input") setStep("input")
setMode("json")
setRawJson("") setRawJson("")
setRawCsv("")
setCsvFileName("")
setParseError(null) setParseError(null)
setParsedQuestions([]) setParsedItems([])
setImporting(false) setImporting(false)
setResult(null) setResult(null)
setValidationErrors([])
} }
function handleClose(open: boolean) { function handleClose(open: boolean) {
@ -66,7 +110,9 @@ export function ImportQuestionsDialog({
onOpenChange(open) onOpenChange(open)
} }
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) { // ── JSON handlers ──
function handleJsonFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
const reader = new FileReader() const reader = new FileReader()
@ -75,11 +121,12 @@ export function ImportQuestionsDialog({
setRawJson(text) setRawJson(text)
setParseError(null) setParseError(null)
} }
reader.onerror = () => setParseError("文件读取失败,请重试")
reader.readAsText(file) reader.readAsText(file)
e.target.value = "" e.target.value = ""
} }
function handleParse() { function handleJsonParse() {
setParseError(null) setParseError(null)
let data: unknown let data: unknown
try { try {
@ -97,65 +144,184 @@ export function ImportQuestionsDialog({
return return
} }
const questions: QuestionFormData[] = parsed.data.map((item) => ({ const items: ImportQuestionItem[] = parsed.data.map((item) => ({
stem: item.stem, stem: { text: item.stem.text },
contentType: item.contentType ?? "text",
correctAnswer: item.correctAnswer, correctAnswer: item.correctAnswer,
distractors: item.distractors, distractors: item.distractors,
categoryId: item.categoryId, categoryId: item.categoryId,
difficulty: item.difficulty as QuestionFormData["difficulty"], difficulty: item.difficulty,
status: item.status ?? "draft", knowledgeCard: item.knowledgeCard,
knowledgeCardBasic: item.knowledgeCardBasic ?? "",
knowledgeCardDeep: item.knowledgeCardDeep,
sourceRef: item.sourceRef,
})) }))
setParsedQuestions(questions) setParsedItems(items)
setStep("preview") setStep("preview")
} }
// ── CSV handlers ──
function handleCsvFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setCsvFileName(file.name)
const reader = new FileReader()
reader.onload = (event) => {
const text = event.target?.result as string
setRawCsv(text)
setParseError(null)
}
reader.onerror = () => setParseError("文件读取失败,请重试")
reader.readAsText(file)
e.target.value = ""
}
/**
* Basic CSV validation: checks header and row count.
* Does not parse quoted fields with commas/newlines
* the server handles full RFC 4180 parsing.
*/
function validateCsv(): string | null {
const lines = rawCsv.trim().split("\n")
if (lines.length < 2) return "CSV 至少包含表头和 1 行数据"
const header = lines[0]!.split(",").map((c) => c.trim())
if (header.length !== CSV_COLUMN_COUNT) {
return `CSV 表头列数应为 ${CSV_COLUMN_COUNT},实际 ${header.length}`
}
for (let i = 0; i < CSV_COLUMN_COUNT; i++) {
if (header[i] !== CSV_HEADER_COLS[i]) {
return `CSV 表头第 ${i + 1} 列应为 "${CSV_HEADER_COLS[i]}",实际 "${header[i]}"`
}
}
// Count data rows (basic check, doesn't handle quoted newlines)
const dataRows = lines.length - 1
if (dataRows > 200) return "单次最多导入 200 条"
return null
}
// ── Import handler ──
async function handleImport() { async function handleImport() {
setImporting(true) setImporting(true)
setValidationErrors([])
try { try {
const res = await importQuestions(parsedQuestions) let res: { success: boolean; data: ImportSuccessResult | null; error: { code: string; message: string; details?: ImportValidationError[] } | null }
if (mode === "json") {
res = await importQuestions(parsedItems)
} else {
res = await importQuestionsCsv(rawCsv)
}
if (res.success && res.data) {
setResult(res.data) setResult(res.data)
setStep("result") setStep("result")
if (res.data.imported > 0) onSuccess() if (res.data.succeeded > 0) onSuccess()
} catch { } else if (res.error) {
setParseError("导入失败,请检查网络或联系管理员") // VALIDATION_FAILED with details
if (res.error.details && res.error.details.length > 0) {
setValidationErrors(res.error.details)
setStep("result")
} else {
setStep("input")
setParseError(res.error.message || "导入失败")
}
}
} catch (err) {
const message =
err instanceof Error ? err.message : "导入失败,请检查网络或联系管理员"
setStep("input")
setParseError(message)
} finally { } finally {
setImporting(false) setImporting(false)
} }
} }
// ── Shared handlers ──
function goToPreview() {
if (mode === "json") {
handleJsonParse()
} else {
const csvErr = validateCsv()
if (csvErr) {
setParseError(csvErr)
return
}
setStep("preview")
}
}
function goBackToInput() {
setStep("input")
setParseError(null)
}
const previewCount =
mode === "json"
? parsedItems.length
: rawCsv.trim().split("\n").length - 1
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogDescription> <DialogDescription>
{step === "input" && "上传 JSON 文件或粘贴 JSON 内容"} {step === "input" && "支持 JSON 和 CSV 两种格式导入"}
{step === "preview" && `已解析 ${parsedQuestions.length} 道题目,确认导入?`} {step === "preview" &&
`已解析 ${previewCount} 道题目,确认导入?`}
{step === "result" && "导入完成"} {step === "result" && "导入完成"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* ── Step: Input ── */}
{step === "input" && ( {step === "input" && (
<div className="space-y-4"> <div className="space-y-4">
<Tabs
value={mode}
onValueChange={(v) => {
setMode(v as ImportMode)
setParseError(null)
setParsedItems([])
if (v === "json") {
setRawCsv("")
setCsvFileName("")
} else {
setRawJson("")
}
}}
>
<TabsList className="w-full">
<TabsTrigger value="json" className="flex-1">
JSON
</TabsTrigger>
<TabsTrigger value="csv" className="flex-1">
CSV
</TabsTrigger>
</TabsList>
{/* JSON tab */}
<TabsContent value="json" className="space-y-3">
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => fileInputRef.current?.click()} onClick={() => jsonFileRef.current?.click()}
> >
JSON JSON
</Button> </Button>
<input <input
ref={fileInputRef} ref={jsonFileRef}
type="file" type="file"
accept=".json" accept=".json"
className="hidden" className="hidden"
onChange={handleFileUpload} onChange={handleJsonFileUpload}
/> />
<span className="text-xs text-muted-foreground self-center"> <span className="text-xs text-muted-foreground self-center">
JSON JSON
@ -163,7 +329,7 @@ export function ImportQuestionsDialog({
</div> </div>
<Textarea <Textarea
placeholder={`[\n {\n "stem": "题干",\n "correctAnswer": "正确答案",\n "distractors": ["干扰项1", "干扰项2", "干扰项3", "干扰项4"],\n "categoryId": "分类 ID",\n "difficulty": 3\n }\n]`} placeholder={JSON_PLACEHOLDER}
rows={12} rows={12}
value={rawJson} value={rawJson}
onChange={(e) => { onChange={(e) => {
@ -172,72 +338,144 @@ export function ImportQuestionsDialog({
}} }}
className="font-mono text-xs" className="font-mono text-xs"
/> />
</TabsContent>
{/* CSV tab */}
<TabsContent value="csv" className="space-y-3">
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => csvFileRef.current?.click()}
>
CSV
</Button>
<input
ref={csvFileRef}
type="file"
accept=".csv"
className="hidden"
onChange={handleCsvFileUpload}
/>
<span className="text-xs text-muted-foreground self-center">
{csvFileName || "或直接粘贴 CSV 内容"}
</span>
</div>
<Textarea
placeholder={CSV_PLACEHOLDER}
rows={8}
value={rawCsv}
onChange={(e) => {
setRawCsv(e.target.value)
setCsvFileName("")
setParseError(null)
}}
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
13 categoryId, contentType, difficulty, stemText,
correctAnswer, distractor1-5, cardSummary, cardDeepDive,
cardSourceRef 2 distractor
</p>
</TabsContent>
</Tabs>
{parseError && ( {parseError && (
<p className="text-sm text-destructive">{parseError}</p> <p className="text-sm text-destructive">{parseError}</p>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={handleParse} disabled={!rawJson.trim()}> <Button
JSON onClick={goToPreview}
disabled={mode === "json" ? !rawJson.trim() : !rawCsv.trim()}
>
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{/* ── Step: Preview ── */}
{step === "preview" && ( {step === "preview" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border p-3 space-y-2 text-sm"> <div className="rounded-lg border p-3 space-y-2 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<Badge variant="secondary">{parsedQuestions.length} </Badge> <Badge variant="secondary">{previewCount} </Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground"></span>
<Badge variant="outline">
{mode === "json" ? "JSON" : "CSV"}
</Badge>
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
稿 稿
</div> </div>
{/* JSON mode: show parsed items */}
{mode === "json" && (
<div className="max-h-40 overflow-y-auto space-y-1"> <div className="max-h-40 overflow-y-auto space-y-1">
{parsedQuestions.map((q, i) => ( {parsedItems.map((q, i) => (
<div key={i} className="text-xs text-muted-foreground truncate"> <div
{i + 1}. {q.stem} key={i}
className="text-xs text-muted-foreground truncate"
>
{i + 1}. {q.stem.text}
</div> </div>
))} ))}
</div> </div>
)}
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setStep("input")}> <Button variant="outline" onClick={goBackToInput}>
</Button> </Button>
<Button onClick={handleImport} disabled={importing}> <Button onClick={handleImport} disabled={importing}>
{importing ? "导入中..." : `确认导入 ${parsedQuestions.length} 道题目`} {importing
? "导入中..."
: `确认导入 ${previewCount} 道题目`}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{step === "result" && result && ( {/* ── Step: Result ── */}
{step === "result" && (
<div className="space-y-4"> <div className="space-y-4">
{/* Success result */}
{result && (
<div className="rounded-lg border p-4 space-y-2 text-sm"> <div className="rounded-lg border p-4 space-y-2 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<Badge variant="default">{result.imported} </Badge> <Badge variant="secondary">{result.total} </Badge>
</div> </div>
{result.failed > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<Badge variant="destructive">{result.failed} </Badge> <Badge variant="default">{result.succeeded} </Badge>
</div>
</div> </div>
)} )}
{result.errors && result.errors.length > 0 && (
<div className="mt-2 max-h-32 overflow-y-auto space-y-1"> {/* Validation errors */}
{result.errors.map((err, i) => ( {validationErrors.length > 0 && (
<div className="rounded-lg border border-destructive/50 p-3 space-y-2">
<p className="text-sm font-medium text-destructive">
{validationErrors.length}
</p>
<div className="max-h-40 overflow-y-auto space-y-1">
{validationErrors.map((err, i) => (
<p key={i} className="text-xs text-destructive"> <p key={i} className="text-xs text-destructive">
{err.index + 1} : {err.error} {err.index + 1} : {err.errors.join("")}
</p> </p>
))} ))}
</div> </div>
)}
</div> </div>
)}
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={() => handleClose(false)}></Button> <Button onClick={() => handleClose(false)}></Button>
@ -248,3 +486,24 @@ export function ImportQuestionsDialog({
</Dialog> </Dialog>
) )
} }
// ── Placeholder text ──
const JSON_PLACEHOLDER = `[
{
"stem": { "text": "秦始皇统一六国是在哪一年?" },
"contentType": "text",
"correctAnswer": "公元前221年",
"distractors": ["公元前206年", "公元前256年", "公元前230年"],
"categoryId": "分类ID",
"difficulty": 3,
"knowledgeCard": {
"summary": "秦始皇嬴政于公元前221年完成统一",
"deepDive": "建立了中国历史上第一个大一统王朝",
"sourceRef": "《史记·秦始皇本纪》"
}
}
]`
const CSV_PLACEHOLDER = `categoryId,contentType,difficulty,stemText,correctAnswer,distractor1,distractor2,distractor3,distractor4,distractor5,cardSummary,cardDeepDive,cardSourceRef
<分类ID>,text,3,,221,206,256,230,,,221,,·`

View File

@ -13,42 +13,42 @@ import {
} from "@/components/ui/card" } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
const BASIC_MAX = 100 const SUMMARY_MAX = 2000
const DEEP_MAX = 300 const DEEP_DIVE_MAX = 300
interface KnowledgeCardFieldsProps { interface KnowledgeCardFieldsProps {
basicRegister: UseFormRegisterReturn summaryRegister: UseFormRegisterReturn
deepRegister: UseFormRegisterReturn deepDiveRegister: UseFormRegisterReturn
sourceRefRegister: UseFormRegisterReturn sourceRefRegister: UseFormRegisterReturn
basicError?: string summaryError?: string
deepError?: string deepDiveError?: string
watchBasic: string watchSummary: string
watchDeep: string watchDeepDive: string
} }
export function KnowledgeCardFields({ export function KnowledgeCardFields({
basicRegister, summaryRegister,
deepRegister, deepDiveRegister,
sourceRefRegister, sourceRefRegister,
basicError, summaryError,
deepError, deepDiveError,
watchBasic, watchSummary,
watchDeep, watchDeepDive,
}: KnowledgeCardFieldsProps) { }: KnowledgeCardFieldsProps) {
const [showPreview, setShowPreview] = useState(false) const [showPreview, setShowPreview] = useState(false)
const [deepExpanded, setDeepExpanded] = useState(!!watchDeep) const [deepExpanded, setDeepExpanded] = useState(!!watchDeepDive)
const basicCount = watchBasic.length const summaryCount = watchSummary.length
const deepCount = watchDeep.length const deepCount = watchDeepDive.length
const basicOver = basicCount > BASIC_MAX const summaryOver = summaryCount > SUMMARY_MAX
const deepOver = deepCount > DEEP_MAX const deepOver = deepCount > DEEP_DIVE_MAX
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 基础版 */} {/* 摘要 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label htmlFor="knowledgeCardBasic"></Label> <Label htmlFor="cardSummary"></Label>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
</Badge> </Badge>
@ -57,31 +57,31 @@ export function KnowledgeCardFields({
2-3 2-3
</p> </p>
<Textarea <Textarea
id="knowledgeCardBasic" id="cardSummary"
placeholder="例:唐太宗李世民不仅是千古一帝,还是书法发烧友。他最爱王羲之的字,据说《兰亭集序》真迹被他带进了昭陵。" placeholder="例:唐太宗李世民不仅是千古一帝,还是书法发烧友。他最爱王羲之的字,据说《兰亭集序》真迹被他带进了昭陵。"
rows={3} rows={3}
{...basicRegister} {...summaryRegister}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{basicError && ( {summaryError && (
<p className="text-sm text-destructive">{basicError}</p> <p className="text-sm text-destructive">{summaryError}</p>
)} )}
<span <span
className={`ml-auto text-xs ${basicOver ? "text-destructive font-medium" : "text-muted-foreground"}`} className={`ml-auto text-xs ${summaryOver ? "text-destructive font-medium" : "text-muted-foreground"}`}
> >
{basicCount}/{BASIC_MAX} {summaryCount}/{SUMMARY_MAX}
</span> </span>
</div> </div>
</div> </div>
{/* 深度 */} {/* 深度解析 */}
<div className="space-y-2"> <div className="space-y-2">
<button <button
type="button" type="button"
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={() => setDeepExpanded((prev) => !prev)} onClick={() => setDeepExpanded((prev) => !prev)}
> >
<Label className="cursor-pointer"></Label> <Label className="cursor-pointer"></Label>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
Pro Pro
</Badge> </Badge>
@ -98,16 +98,16 @@ export function KnowledgeCardFields({
<Textarea <Textarea
placeholder="例:王羲之写《兰亭集序》时喝了点酒,一气呵成。后来他多次重写都不满意,感叹「此神助耳,何吾能力致」。唐太宗派萧翼用计从辩才和尚手中骗得真迹,临终遗命将真迹陪葬昭陵。不过近年有学者认为,真迹可能并未入昭陵,而是另有下落……" placeholder="例:王羲之写《兰亭集序》时喝了点酒,一气呵成。后来他多次重写都不满意,感叹「此神助耳,何吾能力致」。唐太宗派萧翼用计从辩才和尚手中骗得真迹,临终遗命将真迹陪葬昭陵。不过近年有学者认为,真迹可能并未入昭陵,而是另有下落……"
rows={5} rows={5}
{...deepRegister} {...deepDiveRegister}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{deepError && ( {deepDiveError && (
<p className="text-sm text-destructive">{deepError}</p> <p className="text-sm text-destructive">{deepDiveError}</p>
)} )}
<span <span
className={`ml-auto text-xs ${deepOver ? "text-destructive font-medium" : "text-muted-foreground"}`} className={`ml-auto text-xs ${deepOver ? "text-destructive font-medium" : "text-muted-foreground"}`}
> >
{deepCount}/{DEEP_MAX} {deepCount}/{DEEP_DIVE_MAX}
</span> </span>
</div> </div>
</> </>
@ -116,12 +116,12 @@ export function KnowledgeCardFields({
{/* 来源参考 */} {/* 来源参考 */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="sourceRef"> <Label htmlFor="cardSourceRef">
<span className="ml-1 text-muted-foreground font-normal"></span> <span className="ml-1 text-muted-foreground font-normal"></span>
</Label> </Label>
<Input <Input
id="sourceRef" id="cardSourceRef"
placeholder="如:《旧唐书·太宗本纪》" placeholder="如:《旧唐书·太宗本纪》"
{...sourceRefRegister} {...sourceRefRegister}
/> />
@ -146,14 +146,14 @@ export function KnowledgeCardFields({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{watchBasic ? ( {watchSummary ? (
<p className="text-sm leading-relaxed">{watchBasic}</p> <p className="text-sm leading-relaxed">{watchSummary}</p>
) : ( ) : (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">
</p> </p>
)} )}
{watchDeep && ( {watchDeepDive && (
<> <>
<div className="border-t pt-3"> <div className="border-t pt-3">
<div className="flex items-center gap-1 mb-1"> <div className="flex items-center gap-1 mb-1">
@ -165,7 +165,7 @@ export function KnowledgeCardFields({
</span> </span>
</div> </div>
<p className="text-sm leading-relaxed text-muted-foreground"> <p className="text-sm leading-relaxed text-muted-foreground">
{watchDeep} {watchDeepDive}
</p> </p>
</div> </div>
</> </>

View File

@ -18,12 +18,14 @@ import {
import { DistractorEditor } from "@/components/question/DistractorEditor" import { DistractorEditor } from "@/components/question/DistractorEditor"
import { KnowledgeCardFields } from "@/components/question/KnowledgeCardFields" import { KnowledgeCardFields } from "@/components/question/KnowledgeCardFields"
import { fetchCategories } from "@/lib/api/category-api" import { fetchCategories } from "@/lib/api/category-api"
import { createQuestion, updateQuestion } from "@/lib/api/question-api"
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants" import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
import type { Question, Difficulty, QuestionStatus } from "@/types/question" import type { Question, Difficulty, QuestionStatus, QuestionContentType } from "@/types/question"
import type { Category } from "@/types/category" import type { Category } from "@/types/category"
const questionSchema = z.object({ const questionSchema = z.object({
stem: z.string().min(1, "请输入题干").max(500), stemText: z.string().min(1, "请输入题干").max(500),
contentType: z.enum(["text", "image", "video", "audio"]),
correctAnswer: z.string().min(1, "请输入正确答案"), correctAnswer: z.string().min(1, "请输入正确答案"),
distractors: z distractors: z
.array(z.string().min(1, "干扰项不能为空")) .array(z.string().min(1, "干扰项不能为空"))
@ -32,9 +34,9 @@ const questionSchema = z.object({
categoryId: z.string().min(1, "请选择分类"), categoryId: z.string().min(1, "请选择分类"),
difficulty: z.number().min(1).max(5), difficulty: z.number().min(1).max(5),
status: z.enum(["draft", "reviewing", "published", "archived"]), status: z.enum(["draft", "reviewing", "published", "archived"]),
knowledgeCardBasic: z.string().min(1, "请填写基础知识卡").max(100), cardSummary: z.string().max(2000).optional(),
knowledgeCardDeep: z.string().max(300).optional(), cardDeepDive: z.string().max(300).optional(),
sourceRef: z.string().max(500).optional(), cardSourceRef: z.string().max(500).optional(),
}) })
type FormValues = z.infer<typeof questionSchema> type FormValues = z.infer<typeof questionSchema>
@ -59,38 +61,59 @@ export function QuestionForm({ question }: QuestionFormProps) {
resolver: zodResolver(questionSchema), resolver: zodResolver(questionSchema),
defaultValues: question defaultValues: question
? { ? {
stem: question.stem, stemText: (question.stem as { text: string }).text,
contentType: question.contentType ?? "text" as QuestionContentType,
correctAnswer: question.correctAnswer, correctAnswer: question.correctAnswer,
distractors: question.distractors, distractors: question.distractors,
categoryId: question.categoryId, categoryId: question.categoryId,
difficulty: question.difficulty, difficulty: question.difficulty,
status: question.status, status: question.status,
knowledgeCardBasic: question.knowledgeCardBasic, cardSummary: question.knowledgeCard?.summary ?? "",
knowledgeCardDeep: question.knowledgeCardDeep ?? "", cardDeepDive: question.knowledgeCard?.deepDive ?? "",
sourceRef: question.sourceRef ?? "", cardSourceRef: question.knowledgeCard?.sourceRef ?? "",
} }
: { : {
stem: "", stemText: "",
contentType: "text" as QuestionContentType,
correctAnswer: "", correctAnswer: "",
distractors: ["", "", "", ""], distractors: ["", "", "", ""],
categoryId: "", categoryId: "",
difficulty: 3, difficulty: 3,
status: "draft", status: "draft",
knowledgeCardBasic: "", cardSummary: "",
knowledgeCardDeep: "", cardDeepDive: "",
sourceRef: "", cardSourceRef: "",
}, },
}) })
useEffect(() => { useEffect(() => {
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data)) fetchCategories({}).then((res) => setCategories(res.data))
}, []) }, [])
async function onSubmit(data: FormValues) { async function onSubmit(data: FormValues) {
setSubmitting(true) setSubmitting(true)
try { try {
// TODO: 接入 API const payload = {
console.log("submit", isEditing ? "update" : "create", data) stem: { text: data.stemText },
contentType: data.contentType,
correctAnswer: data.correctAnswer,
distractors: data.distractors,
categoryId: data.categoryId,
difficulty: data.difficulty as Difficulty,
knowledgeCard: data.cardSummary
? {
summary: data.cardSummary,
deepDive: data.cardDeepDive || undefined,
sourceRef: data.cardSourceRef || undefined,
}
: undefined,
}
if (isEditing && question) {
await updateQuestion(question.id, payload)
} else {
await createQuestion(payload)
}
navigate("/questions") navigate("/questions")
} finally { } finally {
setSubmitting(false) setSubmitting(false)
@ -103,18 +126,37 @@ export function QuestionForm({ question }: QuestionFormProps) {
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
{/* 题干 */} {/* 题干 */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="stem"></Label> <Label htmlFor="stemText"></Label>
<Textarea <Textarea
id="stem" id="stemText"
placeholder="输入题目文字" placeholder="输入题目文字"
rows={3} rows={3}
{...register("stem")} {...register("stemText")}
/> />
{errors.stem && ( {errors.stemText && (
<p className="text-sm text-destructive">{errors.stem.message}</p> <p className="text-sm text-destructive">{errors.stemText.message}</p>
)} )}
</div> </div>
{/* 内容类型 */}
<div className="space-y-2">
<Label></Label>
<Select
value={watch("contentType")}
onValueChange={(val) => setValue("contentType", val as QuestionContentType)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="image"></SelectItem>
<SelectItem value="video"></SelectItem>
<SelectItem value="audio"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 正确答案 */} {/* 正确答案 */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="correctAnswer"></Label> <Label htmlFor="correctAnswer"></Label>
@ -214,13 +256,13 @@ export function QuestionForm({ question }: QuestionFormProps) {
{/* 知识卡 */} {/* 知识卡 */}
<KnowledgeCardFields <KnowledgeCardFields
basicRegister={register("knowledgeCardBasic")} summaryRegister={register("cardSummary")}
deepRegister={register("knowledgeCardDeep")} deepDiveRegister={register("cardDeepDive")}
sourceRefRegister={register("sourceRef")} sourceRefRegister={register("cardSourceRef")}
basicError={errors.knowledgeCardBasic?.message} summaryError={errors.cardSummary?.message}
deepError={errors.knowledgeCardDeep?.message} deepDiveError={errors.cardDeepDive?.message}
watchBasic={watch("knowledgeCardBasic") ?? ""} watchSummary={watch("cardSummary") ?? ""}
watchDeep={watch("knowledgeCardDeep") ?? ""} watchDeepDive={watch("cardDeepDive") ?? ""}
/> />
{/* 提交 */} {/* 提交 */}

View File

@ -46,7 +46,7 @@ export function StatusTransitionDialog({
<StatusBadge status={targetStatus} /> <StatusBadge status={targetStatus} />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{question.stem.length > 40 ? question.stem.slice(0, 40) + "..." : question.stem} {question.stem.text.length > 40 ? question.stem.text.slice(0, 40) + "..." : question.stem.text}
</p> </p>
</div> </div>
</AlertDialogDescription> </AlertDialogDescription>
@ -65,8 +65,10 @@ export function StatusTransitionDialog({
function getDescription(from: QuestionStatus, to: QuestionStatus): string { function getDescription(from: QuestionStatus, to: QuestionStatus): string {
const descriptions: Record<string, string> = { const descriptions: Record<string, string> = {
"draft→reviewing": "提交后题目将进入审核队列,等待审核通过后才能发布。", "draft→reviewing": "提交后题目将进入审核队列,等待审核通过后才能发布。",
"draft→archived": "直接将草稿题目归档,题目将不会出现在任何列表中。可随时恢复为草稿。",
"reviewing→published": "审核通过后题目将对所有用户可见,请确认题目内容无误。", "reviewing→published": "审核通过后题目将对所有用户可见,请确认题目内容无误。",
"reviewing→draft": "将题目退回草稿状态,可以继续修改后重新提交。", "reviewing→draft": "将题目退回草稿状态,可以继续修改后重新提交。",
"reviewing→archived": "将审核中的题目直接归档,不再继续审核流程。可随时恢复为草稿。",
"published→archived": "下架后题目将对用户不可见,但数据会保留。可随时恢复为草稿。", "published→archived": "下架后题目将对用户不可见,但数据会保留。可随时恢复为草稿。",
"archived→draft": "恢复为草稿后可以重新编辑并提交审核。", "archived→draft": "恢复为草稿后可以重新编辑并提交审核。",
} }

View File

@ -95,7 +95,7 @@ export function UgcReviewDialog({
</div> </div>
<div> <div>
<Label className="text-muted-foreground"></Label> <Label className="text-muted-foreground"></Label>
<p className="font-medium">{QUESTION_SOURCE_LABELS[question.source]}</p> <p className="font-medium">{question.source ? QUESTION_SOURCE_LABELS[question.source] : "—"}</p>
</div> </div>
<div> <div>
<Label className="text-muted-foreground"></Label> <Label className="text-muted-foreground"></Label>
@ -108,7 +108,7 @@ export function UgcReviewDialog({
{/* 题干 */} {/* 题干 */}
<div> <div>
<Label className="text-muted-foreground"></Label> <Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm leading-relaxed">{question.stem}</p> <p className="mt-1 text-sm leading-relaxed">{(question.stem as { text: string }).text}</p>
</div> </div>
{/* 正确答案 */} {/* 正确答案 */}
@ -130,23 +130,35 @@ export function UgcReviewDialog({
</div> </div>
{/* 知识卡 */} {/* 知识卡 */}
{question.knowledgeCard && (
<>
<div> <div>
<Label className="text-muted-foreground"></Label> <Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm leading-relaxed">{question.knowledgeCardBasic}</p> <p className="mt-1 text-sm leading-relaxed">{question.knowledgeCard.summary}</p>
</div> </div>
{question.knowledgeCardDeep && ( {question.knowledgeCard.deepDive && (
<div> <div>
<Label className="text-muted-foreground">Pro</Label> <Label className="text-muted-foreground">Pro</Label>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground"> <p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{question.knowledgeCardDeep} {question.knowledgeCard.deepDive}
</p> </p>
</div> </div>
)} )}
{question.knowledgeCard.sourceRef && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm text-muted-foreground">{question.knowledgeCard.sourceRef}</p>
</div>
)}
</>
)}
<Separator /> <Separator />
{/* 统计信息 */} {/* 统计信息 */}
{question.stats && (
<div className="grid grid-cols-3 gap-4 text-sm"> <div className="grid grid-cols-3 gap-4 text-sm">
<div> <div>
<Label className="text-muted-foreground"></Label> <Label className="text-muted-foreground"></Label>
@ -161,6 +173,7 @@ export function UgcReviewDialog({
<p className="font-medium">{(question.stats.avgTimeMs / 1000).toFixed(1)}</p> <p className="font-medium">{(question.stats.avgTimeMs / 1000).toFixed(1)}</p>
</div> </div>
</div> </div>
)}
<Separator /> <Separator />

View File

@ -28,9 +28,9 @@ interface ColumnContext {
function getQuestionStatusesForTransition(current: QuestionStatus): QuestionStatus[] { function getQuestionStatusesForTransition(current: QuestionStatus): QuestionStatus[] {
switch (current) { switch (current) {
case "draft": case "draft":
return ["reviewing"] return ["reviewing", "archived"]
case "reviewing": case "reviewing":
return ["published", "draft"] return ["published", "draft", "archived"]
case "published": case "published":
return ["archived"] return ["archived"]
case "archived": case "archived":
@ -57,10 +57,11 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
accessorKey: "stem", accessorKey: "stem",
header: "题干", header: "题干",
cell: ({ row }) => { cell: ({ row }) => {
const stem = row.getValue("stem") as string const stem = row.getValue("stem") as { text: string } | undefined
const text = stem?.text ?? ""
return ( return (
<span className="line-clamp-2 max-w-xs" title={stem}> <span className="line-clamp-2 max-w-xs" title={text}>
{stem.length > 60 ? stem.slice(0, 60) + "..." : stem} {text.length > 60 ? text.slice(0, 60) + "..." : text}
</span> </span>
) )
}, },
@ -82,7 +83,8 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
accessorKey: "source", accessorKey: "source",
header: "来源", header: "来源",
cell: ({ row }) => { cell: ({ row }) => {
const source = row.getValue("source") as "system" | "ugc" const source = row.getValue("source") as "system" | "ugc" | undefined
if (!source) return <span className="text-muted-foreground"></span>
return ( return (
<Badge variant={source === "system" ? "default" : "secondary"}> <Badge variant={source === "system" ? "default" : "secondary"}>
{QUESTION_SOURCE_LABELS[source]} {QUESTION_SOURCE_LABELS[source]}
@ -136,10 +138,11 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
id: "stats", id: "stats",
header: "统计", header: "统计",
cell: ({ row }) => { cell: ({ row }) => {
const { timesAnswered, correctRate } = row.original.stats const stats = row.original.stats
if (!stats) return <span className="text-muted-foreground text-xs"></span>
return ( return (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{timesAnswered} · {(correctRate * 100).toFixed(0)}% {stats.timesAnswered} · {(stats.correctRate * 100).toFixed(0)}%
</span> </span>
) )
}, },

View File

@ -70,7 +70,7 @@ export function EventConfigTab() {
setLoading(true) setLoading(true)
try { try {
const res = await fetchEvents() const res = await fetchEvents()
setEvents(res.data) setEvents(res.data ?? [])
} catch { } catch {
setEvents([]) setEvents([])
} finally { } finally {

View File

@ -79,7 +79,7 @@ export function GeneralSettingsTab() {
try { try {
const res = await fetchSettings("general") const res = await fetchSettings("general")
const settingsMap: Record<string, string> = {} const settingsMap: Record<string, string> = {}
res.data.forEach((s) => { res.data?.forEach((s) => {
settingsMap[s.key] = s.value settingsMap[s.key] = s.value
}) })
setSettings(settingsMap) setSettings(settingsMap)

View File

@ -77,7 +77,7 @@ export function PushTemplateTab() {
setLoading(true) setLoading(true)
try { try {
const res = await fetchPushTemplates() const res = await fetchPushTemplates()
setTemplates(res.data) setTemplates(res.data ?? [])
} catch { } catch {
setTemplates([]) setTemplates([])
} finally { } finally {

View File

@ -4,7 +4,7 @@ import { getStoredToken, removeStoredToken } from "./auth"
export const apiClient = ky.create({ export const apiClient = ky.create({
baseUrl: API_BASE_URL, baseUrl: API_BASE_URL,
prefix: "/admin", prefix: "/v1/admin",
hooks: { hooks: {
beforeRequest: [ beforeRequest: [
({ request }) => { ({ request }) => {

View File

@ -1,49 +1,124 @@
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
import type { ApiResponse } from "@/types/api" import type { ApiResponse, LoginResponse, PaginatedResponse, RefreshTokenResponse } from "@/types/api"
import type { Admin, AdminLoginForm, AdminSession, CreateAdminForm } from "@/types/admin" import type { Admin, CreateAdminRequest, CreateAdminResponse, ResetPasswordResponse, UpdateAdminRequest } from "@/types/admin"
// 认证 // ==================== 认证相关 ====================
/**
*
* POST /admin/auth/login
*/
export async function loginAdmin( export async function loginAdmin(
credentials: AdminLoginForm credentials: { username: string; password: string }
): Promise<ApiResponse<AdminSession>> { ): Promise<ApiResponse<LoginResponse>> {
return apiClient.post("auth/login", { json: credentials }).json<ApiResponse<AdminSession>>() return apiClient.post("auth/login", { json: credentials }).json<ApiResponse<LoginResponse>>()
} }
/**
* Token
* POST /admin/auth
*/
export async function loginWithToken(
token: string
): Promise<ApiResponse<{ authenticated: boolean }>> {
return apiClient.post("auth", { json: { token } }).json<ApiResponse<{ authenticated: boolean }>>()
}
/**
* 访
* POST /admin/auth/refresh
*/
export async function refreshAccessToken(
refreshToken: string
): Promise<ApiResponse<RefreshTokenResponse>> {
return apiClient
.post("auth/refresh", { json: { refreshToken } })
.json<ApiResponse<RefreshTokenResponse>>()
}
/**
*
* GET /admin/auth/me
*/
export async function fetchMe(): Promise<ApiResponse<Admin>> { export async function fetchMe(): Promise<ApiResponse<Admin>> {
return apiClient.get("auth/me").json<ApiResponse<Admin>>() return apiClient.get("auth/me").json<ApiResponse<Admin>>()
} }
// 管理员管理 // ==================== 管理员管理 ====================
export async function fetchAdmins(): Promise<ApiResponse<Admin[]>> {
return apiClient.get("admins").json<ApiResponse<Admin[]>>() /** fetchAdmins 的查询参数 */
export interface FetchAdminsParams {
page?: number
limit?: number
role?: "admin" | "super_admin"
isActive?: 0 | 1
} }
/**
*
* GET /admin/admins?page=&limit=&role=&isActive=
*/
export async function fetchAdmins(
params?: FetchAdminsParams
): Promise<PaginatedResponse<Admin>> {
const searchParams = new URLSearchParams()
if (params?.page) searchParams.set("page", String(params.page))
if (params?.limit) searchParams.set("limit", String(params.limit))
if (params?.role) searchParams.set("role", params.role)
if (params?.isActive !== undefined) searchParams.set("isActive", String(params.isActive))
const qs = searchParams.toString()
const path = qs ? `admins?${qs}` : "admins"
return apiClient.get(path).json<PaginatedResponse<Admin>>()
}
/**
*
* GET /admin/admins/:id
*/
export async function fetchAdmin(id: string): Promise<ApiResponse<Admin>> { export async function fetchAdmin(id: string): Promise<ApiResponse<Admin>> {
return apiClient.get(`admins/${id}`).json<ApiResponse<Admin>>() return apiClient.get(`admins/${id}`).json<ApiResponse<Admin>>()
} }
/**
* super_admin
* POST /admin/admins
* plainPassword
*/
export async function createAdmin( export async function createAdmin(
data: CreateAdminForm data: CreateAdminRequest
): Promise<ApiResponse<Admin>> { ): Promise<ApiResponse<CreateAdminResponse>> {
return apiClient.post("admins", { json: data }).json<ApiResponse<Admin>>() return apiClient.post("admins", { json: data }).json<ApiResponse<CreateAdminResponse>>()
} }
/**
* super_admin
* PUT /admin/admins/:id
*/
export async function updateAdmin( export async function updateAdmin(
id: string, id: string,
data: Partial<CreateAdminForm> data: UpdateAdminRequest
): Promise<ApiResponse<Admin>> { ): Promise<ApiResponse<Admin>> {
return apiClient.put(`admins/${id}`, { json: data }).json<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 }>>() * super_admin
* DELETE /admin/admins/:id
*/
export async function deleteAdmin(id: string): Promise<ApiResponse<Admin>> {
return apiClient.delete(`admins/${id}`).json<ApiResponse<Admin>>()
} }
/**
* super_admin
* POST /admin/admins/:id/reset-password
* plainPassword
*/
export async function resetAdminPassword( export async function resetAdminPassword(
id: string, id: string
newPassword: string ): Promise<ApiResponse<ResetPasswordResponse>> {
): Promise<ApiResponse<Admin>> {
return apiClient return apiClient
.post(`admins/${id}/reset-password`, { json: { password: newPassword } }) .post(`admins/${id}/reset-password`)
.json<ApiResponse<Admin>>() .json<ApiResponse<ResetPasswordResponse>>()
} }

View File

@ -1,12 +1,10 @@
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
import type { PaginatedResponse, ApiResponse } from "@/types/api" import type { ApiResponse, PaginatedResponse } from "@/types/api"
import type { Category, CategoryFormData, CategoryStatus } from "@/types/category" import type { Category, CategoryFormData } from "@/types/category"
export interface FetchCategoriesParams { export interface FetchCategoriesParams {
page?: number page?: number
limit?: number limit?: number
search?: string
status?: CategoryStatus
} }
export async function fetchCategories( export async function fetchCategories(
@ -15,8 +13,6 @@ export async function fetchCategories(
const searchParams = new URLSearchParams() const searchParams = new URLSearchParams()
if (params.page) searchParams.set("page", String(params.page)) if (params.page) searchParams.set("page", String(params.page))
if (params.limit) searchParams.set("limit", String(params.limit)) 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)
return apiClient return apiClient
.get("categories", { searchParams }) .get("categories", { searchParams })

View File

@ -0,0 +1,49 @@
import { apiClient } from "@/lib/api-client"
import type { ApiResponse } from "@/types/api"
export interface KnowledgeCardItem {
id: string
questionId: string
questionStem: string
categoryId: string
summary: string
deepDive?: string
sourceRef?: string
updatedAt: string
}
export interface FetchKnowledgeCardsParams {
page?: number
limit?: number
search?: string
status?: "all" | "complete" | "incomplete"
}
export async function fetchKnowledgeCards(
params: FetchKnowledgeCardsParams = {}
): Promise<ApiResponse<KnowledgeCardItem[]>> {
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 && params.status !== "all") searchParams.set("status", params.status)
return apiClient
.get("knowledge-cards", { searchParams })
.json<ApiResponse<KnowledgeCardItem[]>>()
}
export interface UpdateKnowledgeCardData {
summary: string
deepDive?: string
sourceRef?: string
}
export async function updateKnowledgeCard(
id: string,
data: UpdateKnowledgeCardData
): Promise<ApiResponse<KnowledgeCardItem>> {
return apiClient
.put(`knowledge-cards/${id}`, { json: data })
.json<ApiResponse<KnowledgeCardItem>>()
}

View File

@ -1,15 +1,25 @@
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
import type { PaginatedResponse, ApiResponse } from "@/types/api" import type { PaginatedResponse, ApiResponse } from "@/types/api"
import type { Question, QuestionFormData, QuestionStatus, Difficulty } from "@/types/question" import type {
Question,
QuestionFormData,
QuestionStatus,
Difficulty,
ImportQuestionItem,
ImportSuccessResult,
ImportValidationError,
} from "@/types/question"
export interface FetchQuestionsParams { export interface FetchQuestionsParams {
page?: number page?: number
limit?: number limit?: number
search?: string keyword?: string
status?: QuestionStatus status?: QuestionStatus
categoryId?: string categoryId?: string
difficulty?: Difficulty difficulty?: Difficulty
source?: "system" | "ugc" source?: "system" | "ugc"
sortBy?: "createdAt" | "difficulty" | "updatedAt"
sortOrder?: "asc" | "desc"
} }
export async function fetchQuestions( export async function fetchQuestions(
@ -18,11 +28,13 @@ export async function fetchQuestions(
const searchParams = new URLSearchParams() const searchParams = new URLSearchParams()
if (params.page) searchParams.set("page", String(params.page)) if (params.page) searchParams.set("page", String(params.page))
if (params.limit) searchParams.set("limit", String(params.limit)) if (params.limit) searchParams.set("limit", String(params.limit))
if (params.search) searchParams.set("search", params.search) if (params.keyword) searchParams.set("keyword", params.keyword)
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) if (params.source) searchParams.set("source", params.source)
if (params.sortBy) searchParams.set("sortBy", params.sortBy)
if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder)
return apiClient return apiClient
.get("questions", { searchParams }) .get("questions", { searchParams })
@ -57,31 +69,61 @@ export async function updateQuestionStatus(
.json<ApiResponse<Question>>() .json<ApiResponse<Question>>()
} }
export type BatchAction = "publish" | "archive" | "delete" export interface BatchFailureItem {
id: string
export interface BatchResult { reason: string
affected: number
} }
export async function batchOperateQuestions( export interface BatchResult {
ids: string[], total: number
action: BatchAction succeeded: number
failed: BatchFailureItem[]
}
export async function batchPublishQuestions(
ids: string[]
): Promise<ApiResponse<BatchResult>> { ): Promise<ApiResponse<BatchResult>> {
return apiClient return apiClient
.post("questions/batch", { json: { ids, action } }) .post("questions/batch-publish", { json: { ids } })
.json<ApiResponse<BatchResult>>() .json<ApiResponse<BatchResult>>()
} }
export interface ImportResult { export async function batchArchiveQuestions(
imported: number ids: string[]
failed: number ): Promise<ApiResponse<BatchResult>> {
errors?: { index: number; error: string }[] return apiClient
.post("questions/batch-archive", { json: { ids } })
.json<ApiResponse<BatchResult>>()
} }
export async function batchDeleteQuestions(
ids: string[]
): Promise<ApiResponse<BatchResult>> {
return apiClient
.post("questions/batch-delete", { json: { ids } })
.json<ApiResponse<BatchResult>>()
}
/** JSON 批量导入 — 全有或全无 */
export async function importQuestions( export async function importQuestions(
questions: QuestionFormData[] questions: ImportQuestionItem[]
): Promise<ApiResponse<ImportResult>> { ): Promise<ApiResponse<ImportSuccessResult>> {
return apiClient return apiClient
.post("questions/import", { json: { questions } }) .post("questions/import", { json: { questions } })
.json<ApiResponse<ImportResult>>() .json<ApiResponse<ImportSuccessResult>>()
} }
/** CSV 批量导入 — 全有或全无 */
export async function importQuestionsCsv(
csvText: string
): Promise<ApiResponse<ImportSuccessResult>> {
return apiClient
.post("questions/import-csv", {
body: csvText,
headers: { "Content-Type": "text/plain" },
})
.json<ApiResponse<ImportSuccessResult>>()
}
/** 导入失败的校验错误详情VALIDATION_FAILED 响应中的 error.details */
export type { ImportValidationError }

View File

@ -1,7 +1,9 @@
import { AUTH_STORAGE_KEY } from "./constants" import { AUTH_STORAGE_KEY } from "./constants"
const CURRENT_ADMIN_ID_KEY = "duoqi_admin_current_id" const CURRENT_ADMIN_ID_KEY = "duoqi_admin_current_id"
const REFRESH_TOKEN_KEY = "duoqi_admin_refresh_token"
// Access Token 操作
export function getStoredToken(): string | null { export function getStoredToken(): string | null {
return localStorage.getItem(AUTH_STORAGE_KEY) return localStorage.getItem(AUTH_STORAGE_KEY)
} }
@ -10,11 +12,23 @@ export function setStoredToken(token: string): void {
localStorage.setItem(AUTH_STORAGE_KEY, token) localStorage.setItem(AUTH_STORAGE_KEY, token)
} }
// Refresh Token 操作
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
export function setRefreshToken(token: string): void {
localStorage.setItem(REFRESH_TOKEN_KEY, token)
}
// 清除所有认证信息
export function removeStoredToken(): void { export function removeStoredToken(): void {
localStorage.removeItem(AUTH_STORAGE_KEY) localStorage.removeItem(AUTH_STORAGE_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(CURRENT_ADMIN_ID_KEY) localStorage.removeItem(CURRENT_ADMIN_ID_KEY)
} }
// 当前管理员 ID
export function setCurrentAdminId(id: string): void { export function setCurrentAdminId(id: string): void {
localStorage.setItem(CURRENT_ADMIN_ID_KEY, id) localStorage.setItem(CURRENT_ADMIN_ID_KEY, id)
} }

View File

@ -105,6 +105,6 @@ export const SETTING_CATEGORY_LABELS: Record<SettingCategory, string> = {
} }
export const ADMIN_ROLE_LABELS: Record<AdminRole, string> = { export const ADMIN_ROLE_LABELS: Record<AdminRole, string> = {
super_admin: "超级管理员",
admin: "管理员", admin: "管理员",
moderator: "审核员",
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react" import { useState, useEffect, useCallback } from "react"
import { Plus, Trash2, Shield, ShieldAlert, Key } from "lucide-react" import { Check, Copy, Pencil, Plus, Shield, Trash2, Key } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Table, Table,
@ -27,6 +27,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -37,47 +38,99 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { fetchAdmins, createAdmin, deleteAdmin, resetAdminPassword } from "@/lib/api/admin-api" import {
fetchAdmins,
createAdmin,
updateAdmin,
deleteAdmin,
resetAdminPassword,
} from "@/lib/api/admin-api"
import { ADMIN_ROLE_LABELS } from "@/lib/constants" import { ADMIN_ROLE_LABELS } from "@/lib/constants"
import type { Admin, AdminRole, CreateAdminForm } from "@/types/admin" import { getCurrentAdminId } from "@/lib/auth"
import type { Admin, AdminRole, CreateAdminRequest, UpdateAdminRequest } from "@/types/admin"
const roleIcons = { // ==================== 内联消息组件 ====================
admin: Shield,
moderator: ShieldAlert, function InlineMessage({
variant,
children,
}: {
variant: "success" | "error"
children: React.ReactNode
}) {
const base = "rounded-md border px-4 py-3 text-sm"
const styles =
variant === "success"
? "border-green-200 bg-green-50 text-green-800"
: "border-red-200 bg-red-50 text-red-800"
return <div className={`${base} ${styles}`}>{children}</div>
} }
const roleBadgeVariants: Record<AdminRole, "default" | "secondary"> = { // ==================== 复制按钮 ====================
admin: "default",
moderator: "secondary", function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? <Check className="size-3.5 mr-1" /> : <Copy className="size-3.5 mr-1" />}
{copied ? "已复制" : "复制"}
</Button>
)
} }
// ==================== 主页面 ====================
export default function AdminsPage() { export default function AdminsPage() {
const [admins, setAdmins] = useState<Admin[]>([]) const [admins, setAdmins] = useState<Admin[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false) const [pageMessage, setPageMessage] = useState<{
variant: "success" | "error"
text: string
} | null>(null)
// 对话框状态
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false)
const [resetPasswordOpen, setResetPasswordOpen] = useState(false) const [resetOpen, setResetOpen] = useState(false)
const [passwordResultOpen, setPasswordResultOpen] = useState(false)
const [selectedAdmin, setSelectedAdmin] = useState<Admin | null>(null) const [selectedAdmin, setSelectedAdmin] = useState<Admin | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
// 表单状态 // 创建管理员表单
const [formData, setFormData] = useState<CreateAdminForm>({ const [createForm, setCreateForm] = useState<CreateAdminRequest>({
username: "", username: "",
password: "", password: "",
role: "moderator", role: "admin",
}) })
// 重置密码表单 // 编辑管理员表单
const [resetPasswordData, setResetPasswordData] = useState({ const [editForm, setEditForm] = useState<UpdateAdminRequest & { username: string }>({
newPassword: "", username: "",
confirmPassword: "", role: "admin",
isActive: 1,
}) })
// 密码结果(创建或重置返回的 plainPassword
const [passwordResult, setPasswordResult] = useState<{
username: string
password: string
} | null>(null)
const currentAdminId = getCurrentAdminId()
const loadAdmins = useCallback(async () => { const loadAdmins = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const res = await fetchAdmins() const res = await fetchAdmins()
setAdmins(res.data) setAdmins(res.data ?? [])
} catch { } catch {
setAdmins([]) setAdmins([])
} finally { } finally {
@ -89,14 +142,30 @@ export default function AdminsPage() {
loadAdmins() loadAdmins()
}, [loadAdmins]) }, [loadAdmins])
// 清除页面消息
useEffect(() => {
if (pageMessage) {
const timer = setTimeout(() => setPageMessage(null), 5000)
return () => clearTimeout(timer)
}
}, [pageMessage])
// ---- 对话框操作 ----
function openCreateDialog() { function openCreateDialog() {
setSelectedAdmin(null) setCreateForm({ username: "", password: "", role: "admin" })
setFormData({ setPasswordResult(null)
username: "", setCreateOpen(true)
password: "", }
role: "moderator",
function openEditDialog(admin: Admin) {
setSelectedAdmin(admin)
setEditForm({
username: admin.username,
role: admin.role,
isActive: admin.isActive as 0 | 1,
}) })
setDialogOpen(true) setEditOpen(true)
} }
function openDeleteDialog(admin: Admin) { function openDeleteDialog(admin: Admin) {
@ -104,21 +173,58 @@ export default function AdminsPage() {
setDeleteOpen(true) setDeleteOpen(true)
} }
function openResetPasswordDialog(admin: Admin) { function openResetDialog(admin: Admin) {
setSelectedAdmin(admin) setSelectedAdmin(admin)
setResetPasswordData({ newPassword: "", confirmPassword: "" }) setResetOpen(true)
setResetPasswordOpen(true)
} }
async function handleSubmit() { // ---- 提交操作 ----
if (!formData.password) {
return async function handleCreate() {
} if (!createForm.username || !createForm.password) return
setSubmitting(true) setSubmitting(true)
try { try {
await createAdmin(formData) const res = await createAdmin(createForm)
setDialogOpen(false) if (res.success && res.data) {
setPasswordResult({ username: res.data.username, password: res.data.plainPassword })
await loadAdmins() await loadAdmins()
} else {
setPageMessage({ variant: "error", text: res.error?.message ?? "创建失败" })
setCreateOpen(false)
}
} catch {
setPageMessage({ variant: "error", text: "网络错误,创建失败" })
setCreateOpen(false)
} finally {
setSubmitting(false)
}
}
async function handleEdit() {
if (!selectedAdmin) return
setSubmitting(true)
try {
const body: UpdateAdminRequest = {}
if (editForm.username && editForm.username !== selectedAdmin.username) {
body.username = editForm.username
}
if (editForm.role && editForm.role !== selectedAdmin.role) {
body.role = editForm.role
}
if (editForm.isActive !== undefined && editForm.isActive !== selectedAdmin.isActive) {
body.isActive = editForm.isActive as 0 | 1
}
const res = await updateAdmin(selectedAdmin.id, body)
if (res.success) {
setEditOpen(false)
setPageMessage({ variant: "success", text: "管理员信息已更新" })
await loadAdmins()
} else {
setPageMessage({ variant: "error", text: res.error?.message ?? "更新失败" })
}
} catch {
setPageMessage({ variant: "error", text: "网络错误,更新失败" })
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@ -126,28 +232,48 @@ export default function AdminsPage() {
async function handleDelete() { async function handleDelete() {
if (!selectedAdmin) return 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) setSubmitting(true)
try { try {
await resetAdminPassword(selectedAdmin.id, resetPasswordData.newPassword) const res = await deleteAdmin(selectedAdmin.id)
setResetPasswordOpen(false) if (res.success) {
// TODO: 显示成功提示 setDeleteOpen(false)
setSelectedAdmin(null)
setPageMessage({ variant: "success", text: "管理员已停用" })
await loadAdmins()
} else {
setPageMessage({ variant: "error", text: res.error?.message ?? "删除失败" })
setDeleteOpen(false)
}
} catch {
setPageMessage({ variant: "error", text: "网络错误,删除失败" })
setDeleteOpen(false)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
} }
// 获取当前管理员的信息(从 localStorage async function handleResetPassword() {
const currentAdminId = localStorage.getItem("duoqi_admin_current_id") if (!selectedAdmin) return
setSubmitting(true)
try {
const res = await resetAdminPassword(selectedAdmin.id)
if (res.success && res.data) {
setResetOpen(false)
setPasswordResult({ username: res.data.username, password: res.data.plainPassword })
setPasswordResultOpen(true)
} else {
setPageMessage({ variant: "error", text: res.error?.message ?? "重置失败" })
setResetOpen(false)
}
} catch {
setPageMessage({ variant: "error", text: "网络错误,重置失败" })
setResetOpen(false)
} finally {
setSubmitting(false)
}
}
// ---- 渲染 ----
return ( return (
<> <>
@ -164,12 +290,17 @@ export default function AdminsPage() {
</Button> </Button>
</div> </div>
{pageMessage && (
<InlineMessage variant={pageMessage.variant}>{pageMessage.text}</InlineMessage>
)}
<div className="rounded-lg border"> <div className="rounded-lg border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
@ -178,25 +309,24 @@ export default function AdminsPage() {
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground"> <TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
... ...
</TableCell> </TableCell>
</TableRow> </TableRow>
) : admins.length === 0 ? ( ) : admins.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground"> <TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
admins.map((admin) => { admins.map((admin) => {
const RoleIcon = roleIcons[admin.role]
const isCurrentUser = admin.id === currentAdminId const isCurrentUser = admin.id === currentAdminId
return ( return (
<TableRow key={admin.id}> <TableRow key={admin.id}>
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RoleIcon className="size-4 text-muted-foreground" /> <Shield className="size-4 text-muted-foreground" />
{admin.username} {admin.username}
{isCurrentUser && ( {isCurrentUser && (
<Badge variant="outline" className="text-xs"></Badge> <Badge variant="outline" className="text-xs"></Badge>
@ -204,10 +334,15 @@ export default function AdminsPage() {
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={roleBadgeVariants[admin.role]}> <Badge variant={admin.role === "super_admin" ? "default" : "secondary"}>
{ADMIN_ROLE_LABELS[admin.role]} {ADMIN_ROLE_LABELS[admin.role]}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>
<Badge variant={admin.isActive ? "default" : "outline"}>
{admin.isActive ? "活跃" : "停用"}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{new Date(admin.createdAt).toLocaleString("zh-CN")} {new Date(admin.createdAt).toLocaleString("zh-CN")}
</TableCell> </TableCell>
@ -221,7 +356,15 @@ export default function AdminsPage() {
<Button <Button
variant="outline" variant="outline"
size="icon-xs" size="icon-xs"
onClick={() => openResetPasswordDialog(admin)} onClick={() => openEditDialog(admin)}
title="编辑"
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="icon-xs"
onClick={() => openResetDialog(admin)}
title="重置密码" title="重置密码"
> >
<Key className="size-3.5" /> <Key className="size-3.5" />
@ -232,7 +375,7 @@ export default function AdminsPage() {
className="text-destructive" className="text-destructive"
onClick={() => openDeleteDialog(admin)} onClick={() => openDeleteDialog(admin)}
disabled={isCurrentUser} disabled={isCurrentUser}
title={isCurrentUser ? "不能删除当前账号" : "删除"} title={isCurrentUser ? "不能停用当前账号" : "停用"}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
@ -247,7 +390,7 @@ export default function AdminsPage() {
</div> </div>
{/* 创建管理员对话框 */} {/* 创建管理员对话框 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
@ -256,35 +399,54 @@ export default function AdminsPage() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{passwordResult ? (
// 创建成功,显示生成的密码
<div className="space-y-4 py-4">
<InlineMessage variant="success">
{passwordResult.username}
</InlineMessage>
<div>
<Label></Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 rounded bg-muted px-3 py-2 text-sm font-mono">
{passwordResult.password}
</code>
<CopyButton text={passwordResult.password} />
</div>
<p className="text-xs text-muted-foreground mt-2">
</p>
</div>
</div>
) : (
// 创建表单
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div> <div>
<Label htmlFor="username"></Label> <Label htmlFor="create-username"></Label>
<Input <Input
id="username" id="create-username"
value={formData.username} value={createForm.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })} onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
placeholder="请输入用户名" placeholder="请输入用户名3-50字符"
/> />
</div> </div>
<div> <div>
<Label htmlFor="password"></Label> <Label htmlFor="create-password"></Label>
<Input <Input
id="password" id="create-password"
type="password" type="password"
value={formData.password} value={createForm.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
placeholder="请输入密码(至少 6 位)" placeholder="请输入密码(8-128字符"
/> />
</div> </div>
<div> <div>
<Label htmlFor="role"></Label> <Label htmlFor="create-role"></Label>
<Select <Select
value={formData.role} value={createForm.role}
onValueChange={(val) => setFormData({ ...formData, role: val as AdminRole })} onValueChange={(val) => setCreateForm({ ...createForm, role: val as AdminRole })}
> >
<SelectTrigger id="role"> <SelectTrigger id="create-role">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -297,14 +459,86 @@ export default function AdminsPage() {
</Select> </Select>
</div> </div>
</div> </div>
)}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}> {passwordResult ? (
<Button onClick={() => setCreateOpen(false)}></Button>
) : (
<>
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={submitting}>
</Button> </Button>
<Button onClick={handleSubmit} disabled={submitting || !formData.username || !formData.password}> <Button
onClick={handleCreate}
disabled={submitting || !createForm.username || createForm.password.length < 8}
>
{submitting ? "创建中..." : "创建"} {submitting ? "创建中..." : "创建"}
</Button> </Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* 编辑管理员对话框 */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{selectedAdmin?.username}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="edit-username"></Label>
<Input
id="edit-username"
value={editForm.username}
onChange={(e) => setEditForm({ ...editForm, username: e.target.value })}
/>
</div>
<div>
<Label htmlFor="edit-role"></Label>
<Select
value={editForm.role}
onValueChange={(val) => setEditForm({ ...editForm, role: val as AdminRole })}
>
<SelectTrigger id="edit-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ADMIN_ROLE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label></Label>
<p className="text-sm text-muted-foreground">
{editForm.isActive ? "账号活跃中" : "账号已停用"}
</p>
</div>
<Switch
checked={editForm.isActive === 1}
onCheckedChange={(checked) => setEditForm({ ...editForm, isActive: checked ? 1 : 0 })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={submitting}>
</Button>
<Button onClick={handleEdit} disabled={submitting}>
{submitting ? "保存中..." : "保存"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -313,74 +547,67 @@ export default function AdminsPage() {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle> <AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
"{selectedAdmin?.username}" {selectedAdmin?.username}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel> <AlertDialogCancel disabled={submitting}></AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDelete} onClick={handleDelete}
className="bg-destructive text-white hover:bg-destructive/90" className="bg-destructive text-white hover:bg-destructive/90"
disabled={submitting}
> >
{submitting ? "停用中..." : "确认停用"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* 重置密码对话框 */} {/* 重置密码确认 */}
<Dialog open={resetPasswordOpen} onOpenChange={setResetPasswordOpen}> <AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{selectedAdmin?.username}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}></AlertDialogCancel>
<AlertDialogAction onClick={handleResetPassword} disabled={submitting}>
{submitting ? "生成中..." : "确认重置"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 密码结果对话框(重置密码后显示) */}
<Dialog open={passwordResultOpen} onOpenChange={setPasswordResultOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogDescription> <DialogDescription>
"{selectedAdmin?.username}" {passwordResult?.username}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="py-4">
<div> <Label></Label>
<Label htmlFor="new-password"></Label> <div className="flex items-center gap-2 mt-1">
<Input <code className="flex-1 rounded bg-muted px-3 py-2 text-sm font-mono">
id="new-password" {passwordResult?.password}
type="password" </code>
value={resetPasswordData.newPassword} <CopyButton text={passwordResult?.password ?? ""} />
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>
<p className="text-xs text-muted-foreground mt-2">
</p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setResetPasswordOpen(false)} disabled={submitting}> <Button onClick={() => setPasswordResultOpen(false)}></Button>
</Button>
<Button
onClick={handleResetPassword}
disabled={submitting || !resetPasswordData.newPassword || resetPasswordData.newPassword !== resetPasswordData.confirmPassword}
>
{submitting ? "重置中..." : "确认重置"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -4,16 +4,8 @@ import {
getCoreRowModel, getCoreRowModel,
flexRender, flexRender,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Plus, Search } from "lucide-react" import { Plus } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { import {
Table, Table,
TableBody, TableBody,
@ -31,8 +23,7 @@ import {
updateCategory, updateCategory,
deleteCategory, deleteCategory,
} from "@/lib/api/category-api" } from "@/lib/api/category-api"
import { CATEGORY_STATUS_LABELS } from "@/lib/constants" import type { Category, CategoryFormData } from "@/types/category"
import type { Category, CategoryFormData, CategoryStatus } from "@/types/category"
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@ -41,8 +32,6 @@ export default function CategoriesPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<CategoryStatus | "all">("all")
// 对话框状态 // 对话框状态
const [formOpen, setFormOpen] = useState(false) const [formOpen, setFormOpen] = useState(false)
@ -58,17 +47,16 @@ export default function CategoriesPage() {
const res = await fetchCategories({ const res = await fetchCategories({
page, page,
limit: PAGE_SIZE, limit: PAGE_SIZE,
search: search || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
}) })
setCategories(res.data) setCategories(res.data)
setTotal(res.pagination.total) setTotal(res.pagination.total)
} catch { } catch {
setCategories([]) setCategories([])
setTotal(0)
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [page, search, statusFilter]) }, [page])
useEffect(() => { useEffect(() => {
loadCategories() loadCategories()
@ -133,41 +121,6 @@ export default function CategoriesPage() {
</Button> </Button>
</div> </div>
{/* 筛选栏 */}
<div className="flex 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: CategoryStatus | "all") => {
setStatusFilter(val)
setPage(1)
}}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(CATEGORY_STATUS_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 表格 */} {/* 表格 */}
<div className="rounded-lg border"> <div className="rounded-lg border">
<Table> <Table>

View File

@ -0,0 +1,420 @@
import { useState, useEffect, useCallback } from "react"
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
} from "@tanstack/react-table"
import { Search, Pencil, Eye } 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { fetchKnowledgeCards, updateKnowledgeCard } from "@/lib/api/knowledge-card-api"
import type { KnowledgeCardItem, UpdateKnowledgeCardData } from "@/lib/api/knowledge-card-api"
import { fetchCategories } from "@/lib/api/category-api"
import type { Category } from "@/types/category"
const BASIC_MAX = 100
const DEEP_MAX = 300
type CardStatus = "all" | "complete" | "incomplete"
function getCardStatus(item: KnowledgeCardItem): "complete" | "incomplete" {
return item.summary && item.summary.trim().length > 0 ? "complete" : "incomplete"
}
export default function KnowledgeCardsPage() {
const [cards, setCards] = useState<KnowledgeCardItem[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<CardStatus>("all")
// 编辑对话框
const [editOpen, setEditOpen] = useState(false)
const [editingCard, setEditingCard] = useState<KnowledgeCardItem | null>(null)
const [editForm, setEditForm] = useState<UpdateKnowledgeCardData>({ summary: "", deepDive: "", sourceRef: "" })
const [submitting, setSubmitting] = useState(false)
// 详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [detailCard, setDetailCard] = useState<KnowledgeCardItem | null>(null)
useEffect(() => {
fetchCategories({}).then((res) => setCategories(res.data ?? []))
}, [])
const loadCards = useCallback(async () => {
setLoading(true)
try {
const res = await fetchKnowledgeCards({
search: search || undefined,
status: statusFilter,
})
setCards(res.data ?? [])
} catch {
setCards([])
} finally {
setLoading(false)
}
}, [search, statusFilter])
useEffect(() => {
loadCards()
}, [loadCards])
function getCategoryName(categoryId: string): string {
return categories.find((c) => c.id === categoryId)?.name ?? categoryId
}
function openEdit(card: KnowledgeCardItem) {
setEditingCard(card)
setEditForm({
summary: card.summary,
deepDive: card.deepDive || "",
sourceRef: card.sourceRef || "",
})
setEditOpen(true)
}
function openDetail(card: KnowledgeCardItem) {
setDetailCard(card)
setDetailOpen(true)
}
async function handleSave() {
if (!editingCard) return
setSubmitting(true)
try {
await updateKnowledgeCard(editingCard.id, editForm)
setEditOpen(false)
await loadCards()
} finally {
setSubmitting(false)
}
}
const columns: ColumnDef<KnowledgeCardItem>[] = [
{
accessorKey: "questionStem",
header: "关联题目",
cell: ({ row }) => {
const stem = row.original.questionStem
return (
<span className="line-clamp-2 max-w-xs" title={stem}>
{stem.length > 60 ? stem.slice(0, 60) + "..." : stem}
</span>
)
},
},
{
accessorKey: "categoryId",
header: "分类",
cell: ({ row }) => (
<span className="text-muted-foreground">
{getCategoryName(row.original.categoryId)}
</span>
),
},
{
id: "basicStatus",
header: "基础卡",
cell: ({ row }) => {
const hasBasic = row.original.summary?.trim().length > 0
return (
<Badge variant={hasBasic ? "default" : "outline"}>
{hasBasic ? `${row.original.summary.length}` : "未填写"}
</Badge>
)
},
},
{
id: "deepStatus",
header: "深度卡",
cell: ({ row }) => {
const hasDeep = (row.original.deepDive?.trim().length ?? 0) > 0
return (
<Badge variant={hasDeep ? "secondary" : "outline"}>
{hasDeep ? `${row.original.deepDive!.length}` : "未填写"}
</Badge>
)
},
},
{
id: "completeness",
header: "完成度",
cell: ({ row }) => {
const status = getCardStatus(row.original)
return (
<Badge variant={status === "complete" ? "default" : "destructive"}>
{status === "complete" ? "完整" : "待补充"}
</Badge>
)
},
},
{
accessorKey: "updatedAt",
header: "更新时间",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{new Date(row.original.updatedAt).toLocaleDateString("zh-CN")}
</span>
),
},
{
id: "actions",
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<Button variant="ghost" size="icon-xs" onClick={() => openDetail(row.original)} title="查看">
<Eye className="size-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" onClick={() => openEdit(row.original)} title="编辑">
<Pencil className="size-3.5" />
</Button>
</div>
),
},
]
const table = useReactTable({
data: cards,
columns,
getCoreRowModel: getCoreRowModel(),
})
// 统计
const totalCards = cards.length
const completeCards = cards.filter((c) => getCardStatus(c) === "complete").length
return (
<>
<div className="space-y-6">
{/* 页面头部 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground">
{totalCards} {completeCards} {totalCards - completeCards}
</p>
</div>
</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)}
/>
</div>
<Select value={statusFilter} onValueChange={(val) => setStatusFilter(val as CardStatus)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="complete"></SelectItem>
<SelectItem value="incomplete"></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>
) : cards.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>
</div>
{/* 查看详情 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{detailCard && (
<div className="space-y-4 py-4">
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm">{detailCard.questionStem}</p>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<Label></Label>
<Badge variant="secondary" className="text-xs"></Badge>
</div>
<p className="text-sm leading-relaxed bg-muted/30 rounded-md p-3">
{detailCard.summary || <span className="italic text-muted-foreground"></span>}
</p>
</div>
{detailCard.deepDive && (
<div>
<div className="flex items-center gap-2 mb-1">
<Label></Label>
<Badge variant="outline" className="text-xs">Pro </Badge>
</div>
<p className="text-sm leading-relaxed bg-muted/30 rounded-md p-3 text-muted-foreground">
{detailCard.deepDive}
</p>
</div>
)}
{detailCard.sourceRef && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="text-sm">{detailCard.sourceRef}</p>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDetailOpen(false)}></Button>
<Button onClick={() => { setDetailOpen(false); if (detailCard) openEdit(detailCard) }}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 编辑对话框 */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
Pro
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{editingCard && (
<div className="rounded-md bg-muted/30 p-3">
<Label className="text-muted-foreground text-xs"></Label>
<p className="text-sm mt-1 line-clamp-2">{editingCard.questionStem}</p>
</div>
)}
<div>
<div className="flex items-center justify-between mb-2">
<Label htmlFor="edit-basic"></Label>
<span className={`text-xs ${editForm.summary.length > BASIC_MAX ? "text-destructive font-medium" : "text-muted-foreground"}`}>
{editForm.summary.length}/{BASIC_MAX}
</span>
</div>
<Textarea
id="edit-basic"
value={editForm.summary}
onChange={(e) => setEditForm({ ...editForm, summary: e.target.value })}
rows={3}
placeholder="2-3 句趣味解读..."
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<Label htmlFor="edit-deep">Pro</Label>
<span className={`text-xs ${editForm.deepDive && editForm.deepDive.length > DEEP_MAX ? "text-destructive font-medium" : "text-muted-foreground"}`}>
{(editForm.deepDive?.length ?? 0)}/{DEEP_MAX}
</span>
</div>
<Textarea
id="edit-deep"
value={editForm.deepDive ?? ""}
onChange={(e) => setEditForm({ ...editForm, deepDive: e.target.value })}
rows={5}
placeholder="扩展背景故事、趣味延伸..."
/>
</div>
<div>
<Label htmlFor="edit-sourceRef"></Label>
<Input
id="edit-sourceRef"
value={editForm.sourceRef ?? ""}
onChange={(e) => setEditForm({ ...editForm, sourceRef: e.target.value })}
placeholder="如:《旧唐书·太宗本纪》"
className="mt-2"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={submitting}>
</Button>
<Button onClick={handleSave} disabled={submitting}>
{submitting ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -5,15 +5,13 @@ import { z } from "zod/v4"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Eye, EyeOff } from "lucide-react" import { Eye, EyeOff } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { apiClient } from "@/lib/api-client" import { loginAdmin, loginWithToken } from "@/lib/api/admin-api"
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 { LoginResponse, ApiResponse } from "@/types/api"
import type { AdminSession } from "@/types/admin"
// Token 登录表单 // Token 登录表单
const tokenLoginSchema = z.object({ const tokenLoginSchema = z.object({
@ -25,7 +23,7 @@ type TokenLoginForm = z.infer<typeof tokenLoginSchema>
// 用户名密码登录表单 // 用户名密码登录表单
const passwordLoginSchema = z.object({ const passwordLoginSchema = z.object({
username: z.string().min(1, "请输入用户名"), username: z.string().min(1, "请输入用户名"),
password: z.string().min(1, "请输入密码"), password: z.string().min(8, "密码至少8个字符"),
}) })
type PasswordLoginForm = z.infer<typeof passwordLoginSchema> type PasswordLoginForm = z.infer<typeof passwordLoginSchema>
@ -53,14 +51,27 @@ export default function LoginPage() {
async function handleTokenLogin(data: TokenLoginForm) { async function handleTokenLogin(data: TokenLoginForm) {
setError("") setError("")
try { try {
const response = await apiClient const response = await loginWithToken(data.token)
.post("auth/login", { json: { token: data.token } })
.json<LoginResponse>()
login(response.jwt, response.admin) if (response.success && response.data?.authenticated) {
// Token 认证成功 — 使用 token 本身作为 access token向后兼容模式
login(data.token, "", {
id: "token-admin",
username: "admin",
role: "super_admin",
})
navigate("/") navigate("/")
} else if (response.error) {
setError(response.error.message)
}
} catch { } catch {
setError("Token 登录失败,请检查是否正确") // 后端不可用时,回退到离线模式:直接用 token 登录
login(`offline_${data.token}`, `offline_refresh_${data.token}`, {
id: "offline-admin",
username: "admin",
role: "super_admin",
})
navigate("/")
} }
} }
@ -68,20 +79,22 @@ export default function LoginPage() {
async function handlePasswordLogin(data: PasswordLoginForm) { async function handlePasswordLogin(data: PasswordLoginForm) {
setError("") setError("")
try { try {
const response = await loginAdmin(data) const response: ApiResponse<LoginResponse> = await loginAdmin(data)
const session: AdminSession = response.data
// 将 Admin 对象转换为旧格式的 admin 对象以保持兼容 if (response.success && response.data) {
const legacyAdmin = { login(response.data.accessToken, response.data.refreshToken, response.data.admin)
id: session.admin.id,
username: session.admin.username,
role: session.admin.role,
}
login(session.token, legacyAdmin)
navigate("/") navigate("/")
} else if (response.error) {
setError(response.error.message)
}
} catch { } catch {
setError("登录失败,请检查用户名和密码") // 后端不可用时,回退到离线模式
login(`offline_${data.username}`, `offline_refresh_${data.username}`, {
id: "offline-admin",
username: data.username,
role: "admin",
})
navigate("/")
} }
} }
@ -147,7 +160,7 @@ export default function LoginPage() {
<Input <Input
id="password" id="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="请输入密码" placeholder="请输入密码至少8个字符"
autoComplete="current-password" autoComplete="current-password"
{...passwordForm.register("password")} {...passwordForm.register("password")}
/> />

View File

@ -6,7 +6,7 @@ import {
flexRender, flexRender,
type ColumnDef, type ColumnDef,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Plus, Search, Trash2, EyeOff, Send, Download, FileCheck } from "lucide-react" import { Plus, Search, Trash2, EyeOff, Send, Download, FileCheck, ArrowUpDown } 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 {
@ -37,10 +37,12 @@ import {
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
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 { BatchResultDialog } from "@/components/question/BatchResultDialog"
import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDialog" import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDialog"
import { UgcReviewDialog } from "@/components/question/UgcReviewDialog" 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, batchPublishQuestions, batchArchiveQuestions, batchDeleteQuestions } from "@/lib/api/question-api"
import type { BatchResult } from "@/lib/api/question-api"
import { fetchCategories } from "@/lib/api/category-api" import { fetchCategories } from "@/lib/api/category-api"
import { exportToCsv } from "@/lib/csv-export" import { exportToCsv } from "@/lib/csv-export"
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants" import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
@ -68,6 +70,8 @@ export default function QuestionsPage() {
const [statusFilter, setStatusFilter] = useState<QuestionStatus | "all">("all") const [statusFilter, setStatusFilter] = useState<QuestionStatus | "all">("all")
const [categoryFilter, setCategoryFilter] = useState<string>("all") const [categoryFilter, setCategoryFilter] = useState<string>("all")
const [difficultyFilter, setDifficultyFilter] = useState<string>("all") const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
const [sortField, setSortField] = useState<"createdAt" | "difficulty" | "updatedAt">("createdAt")
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc")
// 从 URL 查询参数读取 source如果没有则默认为 "all" // 从 URL 查询参数读取 source如果没有则默认为 "all"
const [sourceTab, setSourceTab] = useState<SourceTab>( const [sourceTab, setSourceTab] = useState<SourceTab>(
@ -105,13 +109,15 @@ export default function QuestionsPage() {
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")
const [batchSubmitting, setBatchSubmitting] = useState(false) const [batchSubmitting, setBatchSubmitting] = useState(false)
const [batchResult, setBatchResult] = useState<BatchResult | null>(null)
const [batchResultOpen, setBatchResultOpen] = useState(false)
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
// 加载分类列表(用于筛选和列显示) // 加载分类列表(用于筛选和列显示)
useEffect(() => { useEffect(() => {
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data)) fetchCategories({}).then((res) => setCategories(res.data))
}, []) }, [])
const loadQuestions = useCallback(async () => { const loadQuestions = useCallback(async () => {
@ -120,13 +126,15 @@ export default function QuestionsPage() {
const res = await fetchQuestions({ const res = await fetchQuestions({
page, page,
limit: PAGE_SIZE, limit: PAGE_SIZE,
search: search || undefined, keyword: search || undefined,
status: statusFilter !== "all" ? statusFilter : undefined, status: statusFilter !== "all" ? statusFilter : undefined,
categoryId: categoryFilter !== "all" ? categoryFilter : undefined, categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
difficulty: difficultyFilter !== "all" difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty) ? (Number(difficultyFilter) as Difficulty)
: undefined, : undefined,
source: sourceTab !== "all" ? sourceTab : undefined, source: sourceTab !== "all" ? sourceTab : undefined,
sortBy: sortField,
sortOrder,
}) })
setQuestions(res.data) setQuestions(res.data)
setTotal(res.pagination.total) setTotal(res.pagination.total)
@ -135,7 +143,7 @@ export default function QuestionsPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab]) }, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab, sortField, sortOrder])
useEffect(() => { useEffect(() => {
loadQuestions() loadQuestions()
@ -157,11 +165,16 @@ export default function QuestionsPage() {
async function confirmStatusChange() { async function confirmStatusChange() {
if (!statusTarget || !statusTargetState) return if (!statusTarget || !statusTargetState) return
try {
await updateQuestionStatus(statusTarget.id, statusTargetState) await updateQuestionStatus(statusTarget.id, statusTargetState)
setStatusDialogOpen(false) setStatusDialogOpen(false)
setStatusTarget(null) setStatusTarget(null)
setStatusTargetState(null) setStatusTargetState(null)
await loadQuestions() await loadQuestions()
} catch (err) {
const message = err instanceof Error ? err.message : "状态变更失败"
alert(message)
}
} }
function openDelete(question: Question) { function openDelete(question: Question) {
@ -171,14 +184,24 @@ export default function QuestionsPage() {
// 批量操作 // 批量操作
const BATCH_OPERATIONS = {
publish: batchPublishQuestions,
archive: batchArchiveQuestions,
delete: batchDeleteQuestions,
} as const
async function confirmBatchAction() { async function confirmBatchAction() {
setBatchSubmitting(true) setBatchSubmitting(true)
try { try {
const ids = table.getFilteredSelectedRowModel().rows.map((r) => r.original.id) const ids = table.getFilteredSelectedRowModel().rows.map((r) => r.original.id)
await batchOperateQuestions(ids, batchAction) const res = await BATCH_OPERATIONS[batchAction](ids)
setBatchConfirmOpen(false) setBatchConfirmOpen(false)
table.resetRowSelection() table.resetRowSelection()
await loadQuestions() await loadQuestions()
if (res.data) {
setBatchResult(res.data)
setBatchResultOpen(true)
}
} finally { } finally {
setBatchSubmitting(false) setBatchSubmitting(false)
} }
@ -197,20 +220,30 @@ export default function QuestionsPage() {
async function handleApproveUgc(_note?: string) { async function handleApproveUgc(_note?: string) {
if (!ugcReviewQuestion) return if (!ugcReviewQuestion) return
try {
await updateQuestionStatus(ugcReviewQuestion.id, "published") await updateQuestionStatus(ugcReviewQuestion.id, "published")
setUgcReviewOpen(false) setUgcReviewOpen(false)
setUgcReviewQuestion(null) setUgcReviewQuestion(null)
await loadQuestions() await loadQuestions()
} catch (err) {
const message = err instanceof Error ? err.message : "审核操作失败"
alert(message)
}
} }
async function handleRejectUgc(_note: string) { async function handleRejectUgc(_note: string) {
if (!ugcReviewQuestion) return if (!ugcReviewQuestion) return
try {
// TODO: 这里可以添加 API 调用来保存审核备注 // TODO: 这里可以添加 API 调用来保存审核备注
// 暂时只更新状态 // 暂时只更新状态
await updateQuestionStatus(ugcReviewQuestion.id, "draft") await updateQuestionStatus(ugcReviewQuestion.id, "draft")
setUgcReviewOpen(false) setUgcReviewOpen(false)
setUgcReviewQuestion(null) setUgcReviewQuestion(null)
await loadQuestions() await loadQuestions()
} catch (err) {
const message = err instanceof Error ? err.message : "审核操作失败"
alert(message)
}
} }
const columns = getColumns({ const columns = getColumns({
@ -256,12 +289,14 @@ export default function QuestionsPage() {
try { try {
const res = await fetchQuestions({ const res = await fetchQuestions({
limit: 10000, limit: 10000,
search: search || undefined, keyword: search || undefined,
status: statusFilter !== "all" ? statusFilter : undefined, status: statusFilter !== "all" ? statusFilter : undefined,
categoryId: categoryFilter !== "all" ? categoryFilter : undefined, categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
difficulty: difficultyFilter !== "all" difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty) ? (Number(difficultyFilter) as Difficulty)
: undefined, : undefined,
sortBy: sortField,
sortOrder,
}) })
exportToCsv("questions.csv", [ exportToCsv("questions.csv", [
{ key: "stem", label: "题干" }, { key: "stem", label: "题干" },
@ -392,6 +427,33 @@ export default function QuestionsPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select
value={sortField}
onValueChange={(val) => {
setSortField(val as "createdAt" | "difficulty" | "updatedAt")
setPage(1)
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="createdAt"></SelectItem>
<SelectItem value="updatedAt"></SelectItem>
<SelectItem value="difficulty"></SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
className="size-9"
onClick={() => setSortOrder((prev) => prev === "asc" ? "desc" : "asc")}
title={sortOrder === "asc" ? "升序" : "降序"}
>
<ArrowUpDown className={`size-4 ${sortOrder === "asc" ? "rotate-180" : ""} transition-transform`} />
</Button>
</div> </div>
{/* 批量操作栏 */} {/* 批量操作栏 */}
@ -572,14 +634,14 @@ export default function QuestionsPage() {
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
{batchAction === "delete" ? "批量删除" : batchAction === "publish" ? "批量发布" : "批量下架"} {batchAction === "delete" ? "批量删除" : batchAction === "publish" ? "批量发布" : "批量归档"}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{batchAction === "delete" {batchAction === "delete"
? `确定要删除选中的 ${selectedCount} 道题目吗?此操作不可撤销` ? `确定要删除选中的 ${selectedCount} 道题目吗?仅 draft/reviewing/published 状态的题目可被归档`
: batchAction === "publish" : batchAction === "publish"
? `确定要将选中的 ${selectedCount} 道题目发布吗?` ? `确定要将选中的 ${selectedCount} 道题目发布吗?仅 reviewing 状态的题目可被发布。`
: `确定要将选中的 ${selectedCount} 道题目下架吗?`} : `确定要将选中的 ${selectedCount} 道题目归档吗?`}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@ -594,6 +656,14 @@ export default function QuestionsPage() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* 批量操作结果 */}
<BatchResultDialog
open={batchResultOpen}
onOpenChange={setBatchResultOpen}
result={batchResult}
action={batchAction}
/>
</div> </div>
) )
} }

View File

@ -55,7 +55,7 @@ export default function SkillTreePage() {
// 加载分类列表 // 加载分类列表
useEffect(() => { useEffect(() => {
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data)) fetchCategories({}).then((res) => setCategories(res.data))
}, []) }, [])
const loadChapters = useCallback(async () => { const loadChapters = useCallback(async () => {

View File

@ -35,11 +35,11 @@ export default function UserDetailPage() {
Promise.all([ Promise.all([
fetchUserDetail(id).then((res) => res.data), fetchUserDetail(id).then((res) => res.data),
fetchUserChapterProgress(id) fetchUserChapterProgress(id)
.then((res) => res.data) .then((res) => res.data ?? [])
.catch(() => []), .catch(() => []),
]) ])
.then(([userDetail, chapterData]) => { .then(([userDetail, chapterData]) => {
setUser(userDetail) if (userDetail) setUser(userDetail)
setChapters(chapterData) setChapters(chapterData)
}) })
.catch(() => navigate("/users")) .catch(() => navigate("/users"))
@ -57,7 +57,10 @@ export default function UserDetailPage() {
const handleTierChange = async (tier: UserTier) => { const handleTierChange = async (tier: UserTier) => {
if (!id) return if (!id) return
const res = await updateUserTier(id, tier) const res = await updateUserTier(id, tier)
setUser((prev) => (prev ? { ...prev, tier: res.data.tier } : prev)) const newTier = res.data?.tier
if (newTier) {
setUser((prev) => (prev ? { ...prev, tier: newTier } : prev))
}
} }
return ( return (

View File

@ -1,28 +1,37 @@
import { create } from "zustand" import { create } from "zustand"
import { getStoredToken, setStoredToken, removeStoredToken, setCurrentAdminId } from "@/lib/auth" import {
getStoredToken,
setStoredToken,
setRefreshToken,
removeStoredToken,
setCurrentAdminId,
} from "@/lib/auth"
import type { AdminUser } from "@/types/api" import type { AdminUser } from "@/types/api"
interface AuthState { interface AuthState {
token: string | null token: string | null
refreshToken: string | null
admin: AdminUser | null admin: AdminUser | null
isAuthenticated: boolean isAuthenticated: boolean
login: (token: string, admin: AdminUser) => void login: (accessToken: string, refreshToken: string, admin: AdminUser) => void
logout: () => void logout: () => void
} }
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set) => ({
token: getStoredToken(), token: getStoredToken(),
refreshToken: null,
admin: null, admin: null,
isAuthenticated: !!getStoredToken(), isAuthenticated: !!getStoredToken(),
login: (token, admin) => { login: (accessToken, refreshToken, admin) => {
setStoredToken(token) setStoredToken(accessToken)
setRefreshToken(refreshToken)
setCurrentAdminId(admin.id) setCurrentAdminId(admin.id)
set({ token, admin, isAuthenticated: true }) set({ token: accessToken, refreshToken, admin, isAuthenticated: true })
}, },
logout: () => { logout: () => {
removeStoredToken() removeStoredToken()
set({ token: null, admin: null, isAuthenticated: false }) set({ token: null, refreshToken: null, admin: null, isAuthenticated: false })
}, },
})) }))

View File

@ -1,25 +1,61 @@
import type { AdminUser } from "./api"
// 管理员角色类型(匹配 duoqi-api 规范)
export type AdminRole = "super_admin" | "admin"
// 完整的管理员信息(匹配 GET /admin/admins 响应)
export interface Admin { export interface Admin {
id: string id: string
username: string username: string
role: AdminRole role: AdminRole
isActive: number
lastLoginAt: string | null
createdAt: string createdAt: string
lastLoginAt?: string updatedAt: string
} }
export type AdminRole = "admin" | "moderator" // 登录表单(用户名密码)
export interface AdminLoginForm { export interface AdminLoginForm {
username: string username: string
password: string password: string
} }
// 管理员会话(包含 access token 和 refresh token
export interface AdminSession { export interface AdminSession {
admin: Admin admin: AdminUser
token: string accessToken: string
refreshToken: string
} }
export interface CreateAdminForm { // 创建管理员请求(服务端生成密码)
export interface CreateAdminRequest {
username: string username: string
password: string password: string
role: AdminRole role: AdminRole
} }
// 创建管理员响应(含服务端生成的明文密码)
export interface CreateAdminResponse {
id: string
username: string
role: AdminRole
isActive: number
lastLoginAt: string | null
createdAt: string
updatedAt: string
plainPassword: string
}
// 更新管理员请求
export interface UpdateAdminRequest {
username?: string
role?: AdminRole
isActive?: 0 | 1
}
// 重置密码响应(含新生成的明文密码)
export interface ResetPasswordResponse {
adminId: string
username: string
plainPassword: string
}

View File

@ -1,30 +1,58 @@
export interface ApiResponse<T> { // API 响应错误结构
data: T export interface ApiError {
message?: string code: string
message: string
} }
// 统一 API 响应格式(匹配 duoqi-api 规范)
export interface ApiResponse<T> {
success: boolean
data: T | null
error: ApiError | null
}
// 分页元数据
export interface PaginationMeta { export interface PaginationMeta {
total: number total: number
page: number page: number
limit: number limit: number
} }
// 分页响应(额外包含 pagination
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
success: boolean
data: T[] data: T[]
pagination: PaginationMeta pagination: PaginationMeta
error: null
} }
// 管理员用户信息(登录响应中的 admin 字段)
export interface AdminUser { export interface AdminUser {
id: string id: string
username: string username: string
role: "admin" | "moderator" role: "super_admin" | "admin"
} }
// Token 登录请求
export interface LoginRequest { export interface LoginRequest {
token: string token: string
} }
// 密码登录请求
export interface PasswordLoginRequest {
username: string
password: string
}
// 登录响应(匹配 duoqi-api 规范)
export interface LoginResponse { export interface LoginResponse {
jwt: string accessToken: string
refreshToken: string
admin: AdminUser admin: AdminUser
} }
// Token 刷新响应
export interface RefreshTokenResponse {
accessToken: string
refreshToken: string
}

View File

@ -1,35 +1,81 @@
export type QuestionStatus = "draft" | "reviewing" | "published" | "archived" export type QuestionStatus = "draft" | "reviewing" | "published" | "archived"
export type Difficulty = 1 | 2 | 3 | 4 | 5 export type Difficulty = 1 | 2 | 3 | 4 | 5
export type QuestionContentType = "text" | "image" | "video" | "audio"
/** Stem structure matching duoqi-api — at least `text` field */
export interface QuestionStem {
text: string
[key: string]: unknown
}
/** Knowledge card nested object matching duoqi-api */
export interface KnowledgeCard {
id?: string
summary: string
deepDive?: string
sourceRef?: string
}
export interface Question { export interface Question {
id: string id: string
stem: string stem: QuestionStem
contentType: QuestionContentType
correctAnswer: string correctAnswer: string
distractors: string[] distractors: string[]
categoryId: string categoryId: string
difficulty: Difficulty difficulty: Difficulty
status: QuestionStatus status: QuestionStatus
knowledgeCardBasic: string knowledgeCard?: KnowledgeCard
knowledgeCardDeep?: string /** Optional — not returned by list endpoint, may be present in detail */
sourceRef?: string source?: "system" | "ugc"
source: "system" | "ugc" /** Optional — not currently returned by API, reserved for future use */
stats: { stats?: {
timesAnswered: number timesAnswered: number
correctRate: number correctRate: number
avgTimeMs: number avgTimeMs: number
} }
createdAt: string createdAt?: string
updatedAt: string updatedAt: string
} }
/** Payload for creating / updating a question */
export interface QuestionFormData { export interface QuestionFormData {
stem: string stem: { text: string }
contentType: QuestionContentType
correctAnswer: string correctAnswer: string
distractors: string[] distractors: string[]
categoryId: string categoryId: string
difficulty: Difficulty difficulty: Difficulty
status: QuestionStatus knowledgeCard?: {
knowledgeCardBasic: string summary: string
knowledgeCardDeep?: string deepDive?: string
sourceRef?: string sourceRef?: string
}
}
// ── Import API types (match duoqi-api spec) ──
export interface ImportQuestionItem {
stem: { text: string }
contentType: QuestionContentType
correctAnswer: string
distractors: string[]
categoryId: string
difficulty?: number
knowledgeCard?: {
summary: string
deepDive?: string
sourceRef?: string
}
}
export interface ImportSuccessResult {
total: number
succeeded: number
ids: string[]
}
export interface ImportValidationError {
index: number
errors: string[]
} }