Compare commits

..

No commits in common. "37b936ec52141fec0afba6c125952bf07b7ca68c" and "2a58fbcbaeb19809f5df904cf5aec1588dc09035" have entirely different histories.

33 changed files with 510 additions and 1907 deletions

View File

@ -14,8 +14,7 @@
"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 3 done + 补全完毕。** UGC 审核 + 举报处理 + 运营配置 + 多管理员支持 已完成。知识卡独立页面和题目列表排序已补全。Phase 1c 数据看板真实数据仍延后。所有计划内功能开发完毕,后续按需迭代。 **Phase 2 done.** 用户详情页 + 反馈管理 + 订阅管理 + CSV 导出 已完成。Phase 1c 数据看板真实数据仍延后。Next: Phase 3 — UGC 审核.
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,37 +33,31 @@ 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, UgcReviewDialog) │ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor, KnowledgeCardFields, StatusTransitionDialog, ImportQuestionsDialog)
│ ├── 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 + current admin ID │ ├── auth.ts # Admin JWT token management
│ ├── 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/report/event/push/admin labels │ └── constants.ts # Status enums, difficulty levels, feedback/tier 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, report, settings, admin, api types └── types/ # question, user, user-detail, feedback, category, 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 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. - **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
- **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 3 已完成UGC 审核、举报处理、运营配置、多管理员)。所有计划内功能开发完毕。* *状态Phase 2 已完成Phase 3 待启动*

View File

@ -6,7 +6,6 @@ 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"
@ -34,7 +33,6 @@ 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,7 +11,6 @@ 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"
@ -21,7 +20,6 @@ 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

@ -1,86 +0,0 @@
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,53 +10,23 @@ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { importQuestions } from "@/lib/api/question-api"
import { import type { QuestionFormData } from "@/types/question"
importQuestions,
importQuestionsCsv,
} from "@/lib/api/question-api"
import type {
ImportQuestionItem,
ImportSuccessResult,
ImportValidationError,
} from "@/types/question"
// ── JSON import schema (matches POST /admin/questions/import) ──
const knowledgeCardSchema = z.object({
summary: z.string().min(1, "知识点摘要不能为空"),
deepDive: z.string().optional(),
sourceRef: z.string().optional(),
})
const importItemSchema = z.object({ const importItemSchema = z.object({
stem: z.object({ text: z.string().min(1, "题干不能为空") }), stem: z.string().min(1, "题干不能为空"),
contentType: z.enum(["text", "image", "video", "audio"]).optional(),
correctAnswer: z.string().min(1, "正确答案不能为空"), correctAnswer: z.string().min(1, "正确答案不能为空"),
distractors: z distractors: z.array(z.string().min(1)).min(4, "至少 4 个干扰项").max(6),
.array(z.string().min(1)) categoryId: z.string().min(1, "请选择分类"),
.min(2, "至少 2 个干扰项") difficulty: z.number().min(1).max(5),
.max(6, "最多 6 个干扰项"), status: z.enum(["draft", "reviewing", "published", "archived"]).optional(),
categoryId: z.string().min(1, "请填写分类 ID"), knowledgeCardBasic: z.string().min(1, "请填写基础知识卡").max(100).optional(),
difficulty: z.number().min(1).max(5).optional(), knowledgeCardDeep: z.string().max(300).optional(),
knowledgeCard: knowledgeCardSchema.optional(), sourceRef: z.string().max(500).optional(),
}) })
const importArraySchema = z const importArraySchema = z.array(importItemSchema).min(1, "至少导入 1 道题目")
.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 {
@ -70,39 +40,25 @@ export function ImportQuestionsDialog({
onOpenChange, onOpenChange,
onSuccess, onSuccess,
}: ImportQuestionsDialogProps) { }: ImportQuestionsDialogProps) {
const jsonFileRef = useRef<HTMLInputElement>(null) const fileInputRef = 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 [parseError, setParseError] = useState<string | null>(null)
// JSON state
const [rawJson, setRawJson] = useState("") const [rawJson, setRawJson] = useState("")
const [parsedItems, setParsedItems] = useState<ImportQuestionItem[]>([]) const [parseError, setParseError] = useState<string | null>(null)
const [parsedQuestions, setParsedQuestions] = useState<QuestionFormData[]>([])
// 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<ImportSuccessResult | null>(null) const [result, setResult] = useState<{
const [validationErrors, setValidationErrors] = useState< imported: number
ImportValidationError[] failed: number
>([]) 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)
setParsedItems([]) setParsedQuestions([])
setImporting(false) setImporting(false)
setResult(null) setResult(null)
setValidationErrors([])
} }
function handleClose(open: boolean) { function handleClose(open: boolean) {
@ -110,9 +66,7 @@ export function ImportQuestionsDialog({
onOpenChange(open) onOpenChange(open)
} }
// ── JSON handlers ── function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
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()
@ -121,12 +75,11 @@ 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 handleJsonParse() { function handleParse() {
setParseError(null) setParseError(null)
let data: unknown let data: unknown
try { try {
@ -144,338 +97,147 @@ export function ImportQuestionsDialog({
return return
} }
const items: ImportQuestionItem[] = parsed.data.map((item) => ({ const questions: QuestionFormData[] = parsed.data.map((item) => ({
stem: { text: item.stem.text }, stem: item.stem,
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, difficulty: item.difficulty as QuestionFormData["difficulty"],
knowledgeCard: item.knowledgeCard, status: item.status ?? "draft",
knowledgeCardBasic: item.knowledgeCardBasic ?? "",
knowledgeCardDeep: item.knowledgeCardDeep,
sourceRef: item.sourceRef,
})) }))
setParsedItems(items) setParsedQuestions(questions)
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 {
let res: { success: boolean; data: ImportSuccessResult | null; error: { code: string; message: string; details?: ImportValidationError[] } | null } const res = await importQuestions(parsedQuestions)
setResult(res.data)
if (mode === "json") { setStep("result")
res = await importQuestions(parsedItems) if (res.data.imported > 0) onSuccess()
} else { } catch {
res = await importQuestionsCsv(rawCsv) setParseError("导入失败,请检查网络或联系管理员")
}
if (res.success && res.data) {
setResult(res.data)
setStep("result")
if (res.data.succeeded > 0) onSuccess()
} else if (res.error) {
// 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 和 CSV 两种格式导入"} {step === "input" && "上传 JSON 文件或粘贴 JSON 内容"}
{step === "preview" && {step === "preview" && `已解析 ${parsedQuestions.length} 道题目,确认导入?`}
`已解析 ${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 <div className="flex gap-2">
value={mode} <Button
onValueChange={(v) => { type="button"
setMode(v as ImportMode) variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
JSON
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileUpload}
/>
<span className="text-xs text-muted-foreground self-center">
JSON
</span>
</div>
<Textarea
placeholder={`[\n {\n "stem": "题干",\n "correctAnswer": "正确答案",\n "distractors": ["干扰项1", "干扰项2", "干扰项3", "干扰项4"],\n "categoryId": "分类 ID",\n "difficulty": 3\n }\n]`}
rows={12}
value={rawJson}
onChange={(e) => {
setRawJson(e.target.value)
setParseError(null) setParseError(null)
setParsedItems([])
if (v === "json") {
setRawCsv("")
setCsvFileName("")
} else {
setRawJson("")
}
}} }}
> className="font-mono text-xs"
<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">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => jsonFileRef.current?.click()}
>
JSON
</Button>
<input
ref={jsonFileRef}
type="file"
accept=".json"
className="hidden"
onChange={handleJsonFileUpload}
/>
<span className="text-xs text-muted-foreground self-center">
JSON
</span>
</div>
<Textarea
placeholder={JSON_PLACEHOLDER}
rows={12}
value={rawJson}
onChange={(e) => {
setRawJson(e.target.value)
setParseError(null)
}}
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 <Button onClick={handleParse} disabled={!rawJson.trim()}>
onClick={goToPreview} JSON
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">{previewCount} </Badge> <Badge variant="secondary">{parsedQuestions.length} </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 className="max-h-40 overflow-y-auto space-y-1">
{parsedQuestions.map((q, i) => (
<div key={i} className="text-xs text-muted-foreground truncate">
{i + 1}. {q.stem}
</div>
))}
</div> </div>
{/* JSON mode: show parsed items */}
{mode === "json" && (
<div className="max-h-40 overflow-y-auto space-y-1">
{parsedItems.map((q, i) => (
<div
key={i}
className="text-xs text-muted-foreground truncate"
>
{i + 1}. {q.stem.text}
</div>
))}
</div>
)}
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={goBackToInput}> <Button variant="outline" onClick={() => setStep("input")}>
</Button> </Button>
<Button onClick={handleImport} disabled={importing}> <Button onClick={handleImport} disabled={importing}>
{importing {importing ? "导入中..." : `确认导入 ${parsedQuestions.length} 道题目`}
? "导入中..."
: `确认导入 ${previewCount} 道题目`}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{/* ── Step: Result ── */} {step === "result" && result && (
{step === "result" && (
<div className="space-y-4"> <div className="space-y-4">
{/* Success result */} <div className="rounded-lg border p-4 space-y-2 text-sm">
{result && ( <div className="flex items-center gap-2">
<div className="rounded-lg border p-4 space-y-2 text-sm"> <span className="text-muted-foreground"></span>
<div className="flex items-center gap-2"> <Badge variant="default">{result.imported} </Badge>
<span className="text-muted-foreground"></span>
<Badge variant="secondary">{result.total} </Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground"></span>
<Badge variant="default">{result.succeeded} </Badge>
</div>
</div> </div>
)} {result.failed > 0 && (
<div className="flex items-center gap-2">
{/* Validation errors */} <span className="text-muted-foreground"></span>
{validationErrors.length > 0 && ( <Badge variant="destructive">{result.failed} </Badge>
<div className="rounded-lg border border-destructive/50 p-3 space-y-2"> </div>
<p className="text-sm font-medium text-destructive"> )}
{validationErrors.length} {result.errors && result.errors.length > 0 && (
</p> <div className="mt-2 max-h-32 overflow-y-auto space-y-1">
<div className="max-h-40 overflow-y-auto space-y-1"> {result.errors.map((err, i) => (
{validationErrors.map((err, i) => (
<p key={i} className="text-xs text-destructive"> <p key={i} className="text-xs text-destructive">
{err.index + 1} : {err.errors.join("")} {err.index + 1} : {err.error}
</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>
@ -486,24 +248,3 @@ 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 SUMMARY_MAX = 2000 const BASIC_MAX = 100
const DEEP_DIVE_MAX = 300 const DEEP_MAX = 300
interface KnowledgeCardFieldsProps { interface KnowledgeCardFieldsProps {
summaryRegister: UseFormRegisterReturn basicRegister: UseFormRegisterReturn
deepDiveRegister: UseFormRegisterReturn deepRegister: UseFormRegisterReturn
sourceRefRegister: UseFormRegisterReturn sourceRefRegister: UseFormRegisterReturn
summaryError?: string basicError?: string
deepDiveError?: string deepError?: string
watchSummary: string watchBasic: string
watchDeepDive: string watchDeep: string
} }
export function KnowledgeCardFields({ export function KnowledgeCardFields({
summaryRegister, basicRegister,
deepDiveRegister, deepRegister,
sourceRefRegister, sourceRefRegister,
summaryError, basicError,
deepDiveError, deepError,
watchSummary, watchBasic,
watchDeepDive, watchDeep,
}: KnowledgeCardFieldsProps) { }: KnowledgeCardFieldsProps) {
const [showPreview, setShowPreview] = useState(false) const [showPreview, setShowPreview] = useState(false)
const [deepExpanded, setDeepExpanded] = useState(!!watchDeepDive) const [deepExpanded, setDeepExpanded] = useState(!!watchDeep)
const summaryCount = watchSummary.length const basicCount = watchBasic.length
const deepCount = watchDeepDive.length const deepCount = watchDeep.length
const summaryOver = summaryCount > SUMMARY_MAX const basicOver = basicCount > BASIC_MAX
const deepOver = deepCount > DEEP_DIVE_MAX const deepOver = deepCount > DEEP_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="cardSummary"></Label> <Label htmlFor="knowledgeCardBasic"></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="cardSummary" id="knowledgeCardBasic"
placeholder="例:唐太宗李世民不仅是千古一帝,还是书法发烧友。他最爱王羲之的字,据说《兰亭集序》真迹被他带进了昭陵。" placeholder="例:唐太宗李世民不仅是千古一帝,还是书法发烧友。他最爱王羲之的字,据说《兰亭集序》真迹被他带进了昭陵。"
rows={3} rows={3}
{...summaryRegister} {...basicRegister}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{summaryError && ( {basicError && (
<p className="text-sm text-destructive">{summaryError}</p> <p className="text-sm text-destructive">{basicError}</p>
)} )}
<span <span
className={`ml-auto text-xs ${summaryOver ? "text-destructive font-medium" : "text-muted-foreground"}`} className={`ml-auto text-xs ${basicOver ? "text-destructive font-medium" : "text-muted-foreground"}`}
> >
{summaryCount}/{SUMMARY_MAX} {basicCount}/{BASIC_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}
{...deepDiveRegister} {...deepRegister}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{deepDiveError && ( {deepError && (
<p className="text-sm text-destructive">{deepDiveError}</p> <p className="text-sm text-destructive">{deepError}</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_DIVE_MAX} {deepCount}/{DEEP_MAX}
</span> </span>
</div> </div>
</> </>
@ -116,12 +116,12 @@ export function KnowledgeCardFields({
{/* 来源参考 */} {/* 来源参考 */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cardSourceRef"> <Label htmlFor="sourceRef">
<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="cardSourceRef" id="sourceRef"
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">
{watchSummary ? ( {watchBasic ? (
<p className="text-sm leading-relaxed">{watchSummary}</p> <p className="text-sm leading-relaxed">{watchBasic}</p>
) : ( ) : (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">
</p> </p>
)} )}
{watchDeepDive && ( {watchDeep && (
<> <>
<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">
{watchDeepDive} {watchDeep}
</p> </p>
</div> </div>
</> </>

View File

@ -18,14 +18,12 @@ 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, QuestionContentType } from "@/types/question" import type { Question, Difficulty, QuestionStatus } from "@/types/question"
import type { Category } from "@/types/category" import type { Category } from "@/types/category"
const questionSchema = z.object({ const questionSchema = z.object({
stemText: z.string().min(1, "请输入题干").max(500), stem: 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, "干扰项不能为空"))
@ -34,9 +32,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"]),
cardSummary: z.string().max(2000).optional(), knowledgeCardBasic: z.string().min(1, "请填写基础知识卡").max(100),
cardDeepDive: z.string().max(300).optional(), knowledgeCardDeep: z.string().max(300).optional(),
cardSourceRef: z.string().max(500).optional(), sourceRef: z.string().max(500).optional(),
}) })
type FormValues = z.infer<typeof questionSchema> type FormValues = z.infer<typeof questionSchema>
@ -61,59 +59,38 @@ export function QuestionForm({ question }: QuestionFormProps) {
resolver: zodResolver(questionSchema), resolver: zodResolver(questionSchema),
defaultValues: question defaultValues: question
? { ? {
stemText: (question.stem as { text: string }).text, stem: question.stem,
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,
cardSummary: question.knowledgeCard?.summary ?? "", knowledgeCardBasic: question.knowledgeCardBasic,
cardDeepDive: question.knowledgeCard?.deepDive ?? "", knowledgeCardDeep: question.knowledgeCardDeep ?? "",
cardSourceRef: question.knowledgeCard?.sourceRef ?? "", sourceRef: question.sourceRef ?? "",
} }
: { : {
stemText: "", stem: "",
contentType: "text" as QuestionContentType,
correctAnswer: "", correctAnswer: "",
distractors: ["", "", "", ""], distractors: ["", "", "", ""],
categoryId: "", categoryId: "",
difficulty: 3, difficulty: 3,
status: "draft", status: "draft",
cardSummary: "", knowledgeCardBasic: "",
cardDeepDive: "", knowledgeCardDeep: "",
cardSourceRef: "", sourceRef: "",
}, },
}) })
useEffect(() => { useEffect(() => {
fetchCategories({}).then((res) => setCategories(res.data)) fetchCategories({ limit: 100 }).then((res) => setCategories(res.data))
}, []) }, [])
async function onSubmit(data: FormValues) { async function onSubmit(data: FormValues) {
setSubmitting(true) setSubmitting(true)
try { try {
const payload = { // TODO: 接入 API
stem: { text: data.stemText }, console.log("submit", isEditing ? "update" : "create", data)
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)
@ -126,37 +103,18 @@ 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="stemText"></Label> <Label htmlFor="stem"></Label>
<Textarea <Textarea
id="stemText" id="stem"
placeholder="输入题目文字" placeholder="输入题目文字"
rows={3} rows={3}
{...register("stemText")} {...register("stem")}
/> />
{errors.stemText && ( {errors.stem && (
<p className="text-sm text-destructive">{errors.stemText.message}</p> <p className="text-sm text-destructive">{errors.stem.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>
@ -256,13 +214,13 @@ export function QuestionForm({ question }: QuestionFormProps) {
{/* 知识卡 */} {/* 知识卡 */}
<KnowledgeCardFields <KnowledgeCardFields
summaryRegister={register("cardSummary")} basicRegister={register("knowledgeCardBasic")}
deepDiveRegister={register("cardDeepDive")} deepRegister={register("knowledgeCardDeep")}
sourceRefRegister={register("cardSourceRef")} sourceRefRegister={register("sourceRef")}
summaryError={errors.cardSummary?.message} basicError={errors.knowledgeCardBasic?.message}
deepDiveError={errors.cardDeepDive?.message} deepError={errors.knowledgeCardDeep?.message}
watchSummary={watch("cardSummary") ?? ""} watchBasic={watch("knowledgeCardBasic") ?? ""}
watchDeepDive={watch("cardDeepDive") ?? ""} watchDeep={watch("knowledgeCardDeep") ?? ""}
/> />
{/* 提交 */} {/* 提交 */}

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.text.length > 40 ? question.stem.text.slice(0, 40) + "..." : question.stem.text} {question.stem.length > 40 ? question.stem.slice(0, 40) + "..." : question.stem}
</p> </p>
</div> </div>
</AlertDialogDescription> </AlertDialogDescription>
@ -65,10 +65,8 @@ 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 ? QUESTION_SOURCE_LABELS[question.source] : "—"}</p> <p className="font-medium">{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 as { text: string }).text}</p> <p className="mt-1 text-sm leading-relaxed">{question.stem}</p>
</div> </div>
{/* 正确答案 */} {/* 正确答案 */}
@ -130,50 +130,37 @@ export function UgcReviewDialog({
</div> </div>
{/* 知识卡 */} {/* 知识卡 */}
{question.knowledgeCard && ( <div>
<> <Label className="text-muted-foreground"></Label>
<div> <p className="mt-1 text-sm leading-relaxed">{question.knowledgeCardBasic}</p>
<Label className="text-muted-foreground"></Label> </div>
<p className="mt-1 text-sm leading-relaxed">{question.knowledgeCard.summary}</p>
</div>
{question.knowledgeCard.deepDive && ( {question.knowledgeCardDeep && (
<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.knowledgeCard.deepDive} {question.knowledgeCardDeep}
</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> <p className="font-medium">{question.stats.timesAnswered}</p>
<p className="font-medium">{question.stats.timesAnswered}</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="font-medium">{(question.stats.correctRate * 100).toFixed(0)}%</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="font-medium">{(question.stats.avgTimeMs / 1000).toFixed(1)}</p>
</div>
</div> </div>
)} <div>
<Label className="text-muted-foreground"></Label>
<p className="font-medium">{(question.stats.correctRate * 100).toFixed(0)}%</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="font-medium">{(question.stats.avgTimeMs / 1000).toFixed(1)}</p>
</div>
</div>
<Separator /> <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", "archived"] return ["reviewing"]
case "reviewing": case "reviewing":
return ["published", "draft", "archived"] return ["published", "draft"]
case "published": case "published":
return ["archived"] return ["archived"]
case "archived": case "archived":
@ -57,11 +57,10 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
accessorKey: "stem", accessorKey: "stem",
header: "题干", header: "题干",
cell: ({ row }) => { cell: ({ row }) => {
const stem = row.getValue("stem") as { text: string } | undefined const stem = row.getValue("stem") as string
const text = stem?.text ?? ""
return ( return (
<span className="line-clamp-2 max-w-xs" title={text}> <span className="line-clamp-2 max-w-xs" title={stem}>
{text.length > 60 ? text.slice(0, 60) + "..." : text} {stem.length > 60 ? stem.slice(0, 60) + "..." : stem}
</span> </span>
) )
}, },
@ -83,8 +82,7 @@ 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" | undefined const source = row.getValue("source") as "system" | "ugc"
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]}
@ -138,11 +136,10 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
id: "stats", id: "stats",
header: "统计", header: "统计",
cell: ({ row }) => { cell: ({ row }) => {
const stats = row.original.stats const { timesAnswered, correctRate } = 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">
{stats.timesAnswered} · {(stats.correctRate * 100).toFixed(0)}% {timesAnswered} · {(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: "/v1/admin", prefix: "/admin",
hooks: { hooks: {
beforeRequest: [ beforeRequest: [
({ request }) => { ({ request }) => {

View File

@ -1,124 +1,49 @@
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
import type { ApiResponse, LoginResponse, PaginatedResponse, RefreshTokenResponse } from "@/types/api" import type { ApiResponse } from "@/types/api"
import type { Admin, CreateAdminRequest, CreateAdminResponse, ResetPasswordResponse, UpdateAdminRequest } from "@/types/admin" import type { Admin, AdminLoginForm, AdminSession, CreateAdminForm } from "@/types/admin"
// ==================== 认证相关 ==================== // 认证
/**
*
* POST /admin/auth/login
*/
export async function loginAdmin( export async function loginAdmin(
credentials: { username: string; password: string } credentials: AdminLoginForm
): Promise<ApiResponse<LoginResponse>> { ): Promise<ApiResponse<AdminSession>> {
return apiClient.post("auth/login", { json: credentials }).json<ApiResponse<LoginResponse>>() return apiClient.post("auth/login", { json: credentials }).json<ApiResponse<AdminSession>>()
} }
/**
* 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[]>> {
/** fetchAdmins 的查询参数 */ return apiClient.get("admins").json<ApiResponse<Admin[]>>()
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: CreateAdminRequest data: CreateAdminForm
): Promise<ApiResponse<CreateAdminResponse>> { ): Promise<ApiResponse<Admin>> {
return apiClient.post("admins", { json: data }).json<ApiResponse<CreateAdminResponse>>() return apiClient.post("admins", { json: data }).json<ApiResponse<Admin>>()
} }
/**
* super_admin
* PUT /admin/admins/:id
*/
export async function updateAdmin( export async function updateAdmin(
id: string, id: string,
data: UpdateAdminRequest data: Partial<CreateAdminForm>
): 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 }>> {
* super_admin return apiClient.delete(`admins/${id}`).json<ApiResponse<{ id: string }>>()
* 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,
): Promise<ApiResponse<ResetPasswordResponse>> { newPassword: string
): Promise<ApiResponse<Admin>> {
return apiClient return apiClient
.post(`admins/${id}/reset-password`) .post(`admins/${id}/reset-password`, { json: { password: newPassword } })
.json<ApiResponse<ResetPasswordResponse>>() .json<ApiResponse<Admin>>()
} }

View File

@ -1,10 +1,12 @@
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
import type { ApiResponse, PaginatedResponse } from "@/types/api" import type { PaginatedResponse, ApiResponse } from "@/types/api"
import type { Category, CategoryFormData } from "@/types/category" import type { Category, CategoryFormData, CategoryStatus } 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(
@ -13,6 +15,8 @@ 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

@ -1,49 +0,0 @@
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,25 +1,15 @@
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 { import type { Question, QuestionFormData, QuestionStatus, Difficulty } from "@/types/question"
Question,
QuestionFormData,
QuestionStatus,
Difficulty,
ImportQuestionItem,
ImportSuccessResult,
ImportValidationError,
} from "@/types/question"
export interface FetchQuestionsParams { export interface FetchQuestionsParams {
page?: number page?: number
limit?: number limit?: number
keyword?: string search?: 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(
@ -28,13 +18,11 @@ 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.keyword) searchParams.set("keyword", params.keyword) if (params.search) searchParams.set("search", params.search)
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 })
@ -69,61 +57,31 @@ export async function updateQuestionStatus(
.json<ApiResponse<Question>>() .json<ApiResponse<Question>>()
} }
export interface BatchFailureItem { export type BatchAction = "publish" | "archive" | "delete"
id: string
reason: string
}
export interface BatchResult { export interface BatchResult {
total: number affected: number
succeeded: number
failed: BatchFailureItem[]
} }
export async function batchPublishQuestions( export async function batchOperateQuestions(
ids: string[] ids: string[],
action: BatchAction
): Promise<ApiResponse<BatchResult>> { ): Promise<ApiResponse<BatchResult>> {
return apiClient return apiClient
.post("questions/batch-publish", { json: { ids } }) .post("questions/batch", { json: { ids, action } })
.json<ApiResponse<BatchResult>>() .json<ApiResponse<BatchResult>>()
} }
export async function batchArchiveQuestions( export interface ImportResult {
ids: string[] imported: number
): Promise<ApiResponse<BatchResult>> { failed: number
return apiClient errors?: { index: number; error: string }[]
.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: ImportQuestionItem[] questions: QuestionFormData[]
): Promise<ApiResponse<ImportSuccessResult>> { ): Promise<ApiResponse<ImportResult>> {
return apiClient return apiClient
.post("questions/import", { json: { questions } }) .post("questions/import", { json: { questions } })
.json<ApiResponse<ImportSuccessResult>>() .json<ApiResponse<ImportResult>>()
} }
/** 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,9 +1,7 @@
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)
} }
@ -12,23 +10,11 @@ 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 { Check, Copy, Pencil, Plus, Shield, Trash2, Key } from "lucide-react" import { Plus, Trash2, Shield, ShieldAlert, Key } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Table, Table,
@ -27,7 +27,6 @@ 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,
@ -38,99 +37,47 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { import { fetchAdmins, createAdmin, deleteAdmin, resetAdminPassword } from "@/lib/api/admin-api"
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 { getCurrentAdminId } from "@/lib/auth" import type { Admin, AdminRole, CreateAdminForm } from "@/types/admin"
import type { Admin, AdminRole, CreateAdminRequest, UpdateAdminRequest } from "@/types/admin"
// ==================== 内联消息组件 ==================== const roleIcons = {
admin: Shield,
function InlineMessage({ moderator: ShieldAlert,
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",
function CopyButton({ text }: { text: string }) { moderator: "secondary",
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 [pageMessage, setPageMessage] = useState<{ const [dialogOpen, setDialogOpen] = useState(false)
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 [resetOpen, setResetOpen] = useState(false) const [resetPasswordOpen, setResetPasswordOpen] = 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 [createForm, setCreateForm] = useState<CreateAdminRequest>({ const [formData, setFormData] = useState<CreateAdminForm>({
username: "", username: "",
password: "", password: "",
role: "admin", role: "moderator",
}) })
// 编辑管理员表单 // 重置密码表单
const [editForm, setEditForm] = useState<UpdateAdminRequest & { username: string }>({ const [resetPasswordData, setResetPasswordData] = useState({
username: "", newPassword: "",
role: "admin", confirmPassword: "",
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 {
@ -142,30 +89,14 @@ 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() {
setCreateForm({ username: "", password: "", role: "admin" }) setSelectedAdmin(null)
setPasswordResult(null) setFormData({
setCreateOpen(true) username: "",
} password: "",
role: "moderator",
function openEditDialog(admin: Admin) {
setSelectedAdmin(admin)
setEditForm({
username: admin.username,
role: admin.role,
isActive: admin.isActive as 0 | 1,
}) })
setEditOpen(true) setDialogOpen(true)
} }
function openDeleteDialog(admin: Admin) { function openDeleteDialog(admin: Admin) {
@ -173,58 +104,21 @@ export default function AdminsPage() {
setDeleteOpen(true) setDeleteOpen(true)
} }
function openResetDialog(admin: Admin) { function openResetPasswordDialog(admin: Admin) {
setSelectedAdmin(admin) setSelectedAdmin(admin)
setResetOpen(true) setResetPasswordData({ newPassword: "", confirmPassword: "" })
setResetPasswordOpen(true)
} }
// ---- 提交操作 ---- async function handleSubmit() {
if (!formData.password) {
async function handleCreate() { return
if (!createForm.username || !createForm.password) return
setSubmitting(true)
try {
const res = await createAdmin(createForm)
if (res.success && res.data) {
setPasswordResult({ username: res.data.username, password: res.data.plainPassword })
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) setSubmitting(true)
try { try {
const body: UpdateAdminRequest = {} await createAdmin(formData)
if (editForm.username && editForm.username !== selectedAdmin.username) { setDialogOpen(false)
body.username = editForm.username await loadAdmins()
}
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)
} }
@ -232,48 +126,28 @@ export default function AdminsPage() {
async function handleDelete() { async function handleDelete() {
if (!selectedAdmin) return if (!selectedAdmin) return
setSubmitting(true) await deleteAdmin(selectedAdmin.id)
try { setDeleteOpen(false)
const res = await deleteAdmin(selectedAdmin.id) setSelectedAdmin(null)
if (res.success) { await loadAdmins()
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 {
setSubmitting(false)
}
} }
async function handleResetPassword() { async function handleResetPassword() {
if (!selectedAdmin) return if (!selectedAdmin || resetPasswordData.newPassword !== resetPasswordData.confirmPassword) {
return
}
setSubmitting(true) setSubmitting(true)
try { try {
const res = await resetAdminPassword(selectedAdmin.id) await resetAdminPassword(selectedAdmin.id, resetPasswordData.newPassword)
if (res.success && res.data) { setResetPasswordOpen(false)
setResetOpen(false) // TODO: 显示成功提示
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 { } finally {
setSubmitting(false) setSubmitting(false)
} }
} }
// ---- 渲染 ---- // 获取当前管理员的信息(从 localStorage
const currentAdminId = localStorage.getItem("duoqi_admin_current_id")
return ( return (
<> <>
@ -290,17 +164,12 @@ 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>
@ -309,24 +178,25 @@ export default function AdminsPage() {
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground"> <TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
... ...
</TableCell> </TableCell>
</TableRow> </TableRow>
) : admins.length === 0 ? ( ) : admins.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground"> <TableCell colSpan={5} 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">
<Shield className="size-4 text-muted-foreground" /> <RoleIcon 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>
@ -334,15 +204,10 @@ export default function AdminsPage() {
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={admin.role === "super_admin" ? "default" : "secondary"}> <Badge variant={roleBadgeVariants[admin.role]}>
{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>
@ -356,15 +221,7 @@ export default function AdminsPage() {
<Button <Button
variant="outline" variant="outline"
size="icon-xs" size="icon-xs"
onClick={() => openEditDialog(admin)} onClick={() => openResetPasswordDialog(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" />
@ -375,7 +232,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>
@ -390,7 +247,7 @@ export default function AdminsPage() {
</div> </div>
{/* 创建管理员对话框 */} {/* 创建管理员对话框 */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
@ -399,114 +256,35 @@ 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>
<Label htmlFor="create-username"></Label>
<Input
id="create-username"
value={createForm.username}
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
placeholder="请输入用户名3-50字符"
/>
</div>
<div>
<Label htmlFor="create-password"></Label>
<Input
id="create-password"
type="password"
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
placeholder="请输入密码8-128字符"
/>
</div>
<div>
<Label htmlFor="create-role"></Label>
<Select
value={createForm.role}
onValueChange={(val) => setCreateForm({ ...createForm, role: val as AdminRole })}
>
<SelectTrigger id="create-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ADMIN_ROLE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<DialogFooter>
{passwordResult ? (
<Button onClick={() => setCreateOpen(false)}></Button>
) : (
<>
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={submitting}>
</Button>
<Button
onClick={handleCreate}
disabled={submitting || !createForm.username || createForm.password.length < 8}
>
{submitting ? "创建中..." : "创建"}
</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 className="space-y-4 py-4">
<div> <div>
<Label htmlFor="edit-username"></Label> <Label htmlFor="username"></Label>
<Input <Input
id="edit-username" id="username"
value={editForm.username} value={formData.username}
onChange={(e) => setEditForm({ ...editForm, username: e.target.value })} onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="请输入用户名"
/> />
</div> </div>
<div> <div>
<Label htmlFor="edit-role"></Label> <Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="请输入密码(至少 6 位)"
/>
</div>
<div>
<Label htmlFor="role"></Label>
<Select <Select
value={editForm.role} value={formData.role}
onValueChange={(val) => setEditForm({ ...editForm, role: val as AdminRole })} onValueChange={(val) => setFormData({ ...formData, role: val as AdminRole })}
> >
<SelectTrigger id="edit-role"> <SelectTrigger id="role">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -518,26 +296,14 @@ export default function AdminsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={submitting}> <Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}>
</Button> </Button>
<Button onClick={handleEdit} disabled={submitting}> <Button onClick={handleSubmit} disabled={submitting || !formData.username || !formData.password}>
{submitting ? "保存中..." : "保存"} {submitting ? "创建中..." : "创建"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -547,67 +313,74 @@ 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 disabled={submitting}></AlertDialogCancel> <AlertDialogCancel></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>
{/* 重置密码确认 */} {/* 重置密码对话框 */}
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}> <Dialog open={resetPasswordOpen} onOpenChange={setResetPasswordOpen}>
<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>
{passwordResult?.username} "{selectedAdmin?.username}"
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-4"> <div className="space-y-4 py-4">
<Label></Label> <div>
<div className="flex items-center gap-2 mt-1"> <Label htmlFor="new-password"></Label>
<code className="flex-1 rounded bg-muted px-3 py-2 text-sm font-mono"> <Input
{passwordResult?.password} id="new-password"
</code> type="password"
<CopyButton text={passwordResult?.password ?? ""} /> value={resetPasswordData.newPassword}
onChange={(e) =>
setResetPasswordData({ ...resetPasswordData, newPassword: e.target.value })
}
placeholder="请输入新密码(至少 6 位)"
/>
</div>
<div>
<Label htmlFor="confirm-password"></Label>
<Input
id="confirm-password"
type="password"
value={resetPasswordData.confirmPassword}
onChange={(e) =>
setResetPasswordData({ ...resetPasswordData, confirmPassword: e.target.value })
}
placeholder="请再次输入新密码"
/>
{resetPasswordData.newPassword !== resetPasswordData.confirmPassword && (
<p className="text-sm text-destructive mt-1"></p>
)}
</div> </div>
<p className="text-xs text-muted-foreground mt-2">
</p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button onClick={() => setPasswordResultOpen(false)}></Button> <Button variant="outline" onClick={() => setResetPasswordOpen(false)} disabled={submitting}>
</Button>
<Button
onClick={handleResetPassword}
disabled={submitting || !resetPasswordData.newPassword || resetPasswordData.newPassword !== resetPasswordData.confirmPassword}
>
{submitting ? "重置中..." : "确认重置"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -4,8 +4,16 @@ import {
getCoreRowModel, getCoreRowModel,
flexRender, flexRender,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Plus } from "lucide-react" import { Plus, Search } 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,
@ -23,7 +31,8 @@ import {
updateCategory, updateCategory,
deleteCategory, deleteCategory,
} from "@/lib/api/category-api" } from "@/lib/api/category-api"
import type { Category, CategoryFormData } from "@/types/category" import { CATEGORY_STATUS_LABELS } from "@/lib/constants"
import type { Category, CategoryFormData, CategoryStatus } from "@/types/category"
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@ -32,6 +41,8 @@ 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)
@ -47,16 +58,17 @@ 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]) }, [page, search, statusFilter])
useEffect(() => { useEffect(() => {
loadCategories() loadCategories()
@ -121,6 +133,41 @@ 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

@ -1,420 +0,0 @@
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,13 +5,15 @@ 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 { loginAdmin, loginWithToken } from "@/lib/api/admin-api" import { apiClient } from "@/lib/api-client"
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, ApiResponse } from "@/types/api" import type { LoginResponse } from "@/types/api"
import type { AdminSession } from "@/types/admin"
// Token 登录表单 // Token 登录表单
const tokenLoginSchema = z.object({ const tokenLoginSchema = z.object({
@ -23,7 +25,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(8, "密码至少8个字符"), password: z.string().min(1, "请输入密码"),
}) })
type PasswordLoginForm = z.infer<typeof passwordLoginSchema> type PasswordLoginForm = z.infer<typeof passwordLoginSchema>
@ -51,27 +53,14 @@ export default function LoginPage() {
async function handleTokenLogin(data: TokenLoginForm) { async function handleTokenLogin(data: TokenLoginForm) {
setError("") setError("")
try { try {
const response = await loginWithToken(data.token) const response = await apiClient
.post("auth/login", { json: { token: data.token } })
.json<LoginResponse>()
if (response.success && response.data?.authenticated) { login(response.jwt, response.admin)
// Token 认证成功 — 使用 token 本身作为 access token向后兼容模式
login(data.token, "", {
id: "token-admin",
username: "admin",
role: "super_admin",
})
navigate("/")
} else if (response.error) {
setError(response.error.message)
}
} catch {
// 后端不可用时,回退到离线模式:直接用 token 登录
login(`offline_${data.token}`, `offline_refresh_${data.token}`, {
id: "offline-admin",
username: "admin",
role: "super_admin",
})
navigate("/") navigate("/")
} catch {
setError("Token 登录失败,请检查是否正确")
} }
} }
@ -79,22 +68,20 @@ export default function LoginPage() {
async function handlePasswordLogin(data: PasswordLoginForm) { async function handlePasswordLogin(data: PasswordLoginForm) {
setError("") setError("")
try { try {
const response: ApiResponse<LoginResponse> = await loginAdmin(data) const response = await loginAdmin(data)
const session: AdminSession = response.data
if (response.success && response.data) { // 将 Admin 对象转换为旧格式的 admin 对象以保持兼容
login(response.data.accessToken, response.data.refreshToken, response.data.admin) const legacyAdmin = {
navigate("/") id: session.admin.id,
} else if (response.error) { username: session.admin.username,
setError(response.error.message) role: session.admin.role,
} }
} catch {
// 后端不可用时,回退到离线模式 login(session.token, legacyAdmin)
login(`offline_${data.username}`, `offline_refresh_${data.username}`, {
id: "offline-admin",
username: data.username,
role: "admin",
})
navigate("/") navigate("/")
} catch {
setError("登录失败,请检查用户名和密码")
} }
} }
@ -160,7 +147,7 @@ export default function LoginPage() {
<Input <Input
id="password" id="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="请输入密码至少8个字符" placeholder="请输入密码"
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, ArrowUpDown } from "lucide-react" import { Plus, Search, Trash2, EyeOff, Send, Download, FileCheck } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
@ -37,12 +37,10 @@ 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, batchPublishQuestions, batchArchiveQuestions, batchDeleteQuestions } from "@/lib/api/question-api" import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchOperateQuestions } 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"
@ -70,8 +68,6 @@ 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>(
@ -109,15 +105,13 @@ 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({}).then((res) => setCategories(res.data)) fetchCategories({ limit: 100 }).then((res) => setCategories(res.data))
}, []) }, [])
const loadQuestions = useCallback(async () => { const loadQuestions = useCallback(async () => {
@ -126,15 +120,13 @@ export default function QuestionsPage() {
const res = await fetchQuestions({ const res = await fetchQuestions({
page, page,
limit: PAGE_SIZE, limit: PAGE_SIZE,
keyword: search || undefined, search: 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)
@ -143,7 +135,7 @@ export default function QuestionsPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab, sortField, sortOrder]) }, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab])
useEffect(() => { useEffect(() => {
loadQuestions() loadQuestions()
@ -165,16 +157,11 @@ 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) {
@ -184,24 +171,14 @@ 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)
const res = await BATCH_OPERATIONS[batchAction](ids) await batchOperateQuestions(ids, batchAction)
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)
} }
@ -220,30 +197,20 @@ 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({
@ -289,14 +256,12 @@ export default function QuestionsPage() {
try { try {
const res = await fetchQuestions({ const res = await fetchQuestions({
limit: 10000, limit: 10000,
keyword: search || undefined, search: 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: "题干" },
@ -427,33 +392,6 @@ 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>
{/* 批量操作栏 */} {/* 批量操作栏 */}
@ -634,14 +572,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} 道题目吗?仅 draft/reviewing/published 状态的题目可被归档` ? `确定要删除选中的 ${selectedCount} 道题目吗?此操作不可撤销`
: batchAction === "publish" : batchAction === "publish"
? `确定要将选中的 ${selectedCount} 道题目发布吗?仅 reviewing 状态的题目可被发布。` ? `确定要将选中的 ${selectedCount} 道题目发布吗?`
: `确定要将选中的 ${selectedCount} 道题目归档吗?`} : `确定要将选中的 ${selectedCount} 道题目下架吗?`}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@ -656,14 +594,6 @@ 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({}).then((res) => setCategories(res.data)) fetchCategories({ limit: 100 }).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]) => {
if (userDetail) setUser(userDetail) setUser(userDetail)
setChapters(chapterData) setChapters(chapterData)
}) })
.catch(() => navigate("/users")) .catch(() => navigate("/users"))
@ -57,10 +57,7 @@ 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)
const newTier = res.data?.tier setUser((prev) => (prev ? { ...prev, tier: res.data.tier } : prev))
if (newTier) {
setUser((prev) => (prev ? { ...prev, tier: newTier } : prev))
}
} }
return ( return (

View File

@ -1,37 +1,28 @@
import { create } from "zustand" import { create } from "zustand"
import { import { getStoredToken, setStoredToken, removeStoredToken, setCurrentAdminId } from "@/lib/auth"
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: (accessToken: string, refreshToken: string, admin: AdminUser) => void login: (token: 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: (accessToken, refreshToken, admin) => { login: (token, admin) => {
setStoredToken(accessToken) setStoredToken(token)
setRefreshToken(refreshToken)
setCurrentAdminId(admin.id) setCurrentAdminId(admin.id)
set({ token: accessToken, refreshToken, admin, isAuthenticated: true }) set({ token, admin, isAuthenticated: true })
}, },
logout: () => { logout: () => {
removeStoredToken() removeStoredToken()
set({ token: null, refreshToken: null, admin: null, isAuthenticated: false }) set({ token: null, admin: null, isAuthenticated: false })
}, },
})) }))

View File

@ -1,61 +1,25 @@
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
updatedAt: string lastLoginAt?: 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: AdminUser admin: Admin
accessToken: string token: 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,58 +1,30 @@
// API 响应错误结构
export interface ApiError {
code: string
message: string
}
// 统一 API 响应格式(匹配 duoqi-api 规范)
export interface ApiResponse<T> { export interface ApiResponse<T> {
success: boolean data: T
data: T | null message?: string
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: "super_admin" | "admin" role: "admin" | "moderator"
} }
// 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 {
accessToken: string jwt: string
refreshToken: string
admin: AdminUser admin: AdminUser
} }
// Token 刷新响应
export interface RefreshTokenResponse {
accessToken: string
refreshToken: string
}

View File

@ -1,81 +1,35 @@
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: QuestionStem stem: string
contentType: QuestionContentType
correctAnswer: string correctAnswer: string
distractors: string[] distractors: string[]
categoryId: string categoryId: string
difficulty: Difficulty difficulty: Difficulty
status: QuestionStatus status: QuestionStatus
knowledgeCard?: KnowledgeCard knowledgeCardBasic: string
/** Optional — not returned by list endpoint, may be present in detail */ knowledgeCardDeep?: string
source?: "system" | "ugc" sourceRef?: string
/** Optional — not currently returned by API, reserved for future use */ source: "system" | "ugc"
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: { text: string } stem: string
contentType: QuestionContentType
correctAnswer: string correctAnswer: string
distractors: string[] distractors: string[]
categoryId: string categoryId: string
difficulty: Difficulty difficulty: Difficulty
knowledgeCard?: { status: QuestionStatus
summary: string knowledgeCardBasic: string
deepDive?: string knowledgeCardDeep?: 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[]
} }