Compare commits
10 Commits
2a58fbcbae
...
37b936ec52
| Author | SHA1 | Date | |
|---|---|---|---|
| 37b936ec52 | |||
| a822e91c63 | |||
| 4cb26daa02 | |||
| d1af1dbe11 | |||
| 8e3d4ed190 | |||
| 66fc078b3c | |||
| 2c2fc952f9 | |||
| b6dc6848af | |||
| 1fc27207e0 | |||
| 87a1f39d51 |
@ -14,7 +14,8 @@
|
||||
"Bash(bunx:*)",
|
||||
"Bash(bun run:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
"Bash(git commit:*)",
|
||||
"mcp__plugin_ecc_sequential-thinking__sequentialthinking"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
18
CLAUDE.md
18
CLAUDE.md
@ -5,7 +5,7 @@
|
||||
|
||||
## Current Status
|
||||
|
||||
**Phase 2 done.** 用户详情页 + 反馈管理 + 订阅管理 + CSV 导出 已完成。Phase 1c 数据看板真实数据仍延后。Next: Phase 3 — UGC 审核.
|
||||
**Phase 3 done + 补全完毕。** UGC 审核 + 举报处理 + 运营配置 + 多管理员支持 已完成。知识卡独立页面和题目列表排序已补全。Phase 1c 数据看板真实数据仍延后。所有计划内功能开发完毕,后续按需迭代。
|
||||
|
||||
Development follows the phased roadmap in [dev-spec.md](./dev-spec.md) §九.
|
||||
|
||||
@ -33,31 +33,37 @@ src/
|
||||
├── App.tsx # Root (router + layout)
|
||||
├── styles/globals.css # Tailwind v4 + shadcn/ui CSS variables
|
||||
├── routes/ # Pages (login, dashboard, questions/*, categories/*, ...)
|
||||
│ ├── knowledge-cards/ # Knowledge card management (知识卡管理)
|
||||
│ ├── reports/ # Report handling (举报处理)
|
||||
│ ├── admins/ # Admin management (管理员管理)
|
||||
│ └── settings/ # System settings (运营配置)
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui primitives
|
||||
│ ├── layout/ # Sidebar, Header, AdminLayout
|
||||
│ ├── charts/ # StatsCard, chart wrappers
|
||||
│ ├── category/ # Category CRUD (columns, dialogs)
|
||||
│ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor, KnowledgeCardFields, StatusTransitionDialog, ImportQuestionsDialog)
|
||||
│ ├── question/ # Question CRUD (columns, form, StatusBadge, DistractorEditor, KnowledgeCardFields, StatusTransitionDialog, ImportQuestionsDialog, UgcReviewDialog)
|
||||
│ ├── skill-tree/ # Skill Tree chapter CRUD (columns, form dialog, delete dialog)
|
||||
│ ├── user/ # User CRUD (columns, UserProfileCard, GameStatsGrid, AnswerHistoryTable, ChapterProgressList, TierChangeDialog)
|
||||
│ ├── feedback/ # Feedback management (columns, FeedbackDetailDialog)
|
||||
│ └── settings/ # Settings tabs (EventConfigTab, PushTemplateTab, GeneralSettingsTab)
|
||||
├── lib/
|
||||
│ ├── api-client.ts # HTTP client for /admin/* endpoints
|
||||
│ ├── auth.ts # Admin JWT token management
|
||||
│ ├── auth.ts # Admin JWT token management + current admin ID
|
||||
│ ├── utils.ts # Utility functions
|
||||
│ ├── csv-export.ts # Generic CSV export utility (BOM-compatible)
|
||||
│ └── constants.ts # Status enums, difficulty levels, feedback/tier labels
|
||||
│ ├── constants.ts # Status enums, difficulty levels, feedback/tier/report/event/push/admin labels
|
||||
│ └── api/ # API modules (question-api, category-api, report-api, settings-api, admin-api)
|
||||
├── hooks/ # useAuth
|
||||
├── stores/ # Zustand stores (auth-store)
|
||||
└── types/ # question, user, user-detail, feedback, category, api types
|
||||
└── types/ # question, user, user-detail, feedback, category, report, settings, admin, api types
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **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).
|
||||
- **Auth flow**: Login page → POST `/admin/auth/login` with token → receive admin JWT → store in auth-store → attach as `Authorization: Bearer <jwt>` on all subsequent requests
|
||||
- **Auth flow**: Login page supports Token and username/password login. Token: POST `/admin/auth/login` → receive JWT. Password: POST `/admin/auth/login` with credentials → receive JWT. Both modes fall back to offline mode when backend is unavailable (stores a `offline_` prefixed token locally). JWT stored in auth-store → attached as `Authorization: Bearer <jwt>` on all subsequent requests.
|
||||
- **API client**: All admin API calls go through `lib/api-client.ts` (ky v2). Uses `baseUrl` + `prefix: "/admin"`. Auto-attaches auth header. 401 responses trigger logout + redirect to `/login`.
|
||||
- **Data tables**: TanStack Table v8 headless + shadcn/ui styled components in `components/data-table/`
|
||||
- **Forms**: React Hook Form + Zod validation for all admin forms
|
||||
|
||||
@ -414,4 +414,4 @@ VITE_API_BASE_URL=http://localhost:3000
|
||||
---
|
||||
|
||||
*创建日期:2026-04-06*
|
||||
*状态:Phase 2 已完成,Phase 3 待启动*
|
||||
*状态:Phase 3 已完成(UGC 审核、举报处理、运营配置、多管理员)。所有计划内功能开发完毕。*
|
||||
|
||||
@ -6,6 +6,7 @@ import QuestionsPage from "@/routes/questions"
|
||||
import NewQuestionPage from "@/routes/questions/new"
|
||||
import EditQuestionPage from "@/routes/questions/$id.edit"
|
||||
import CategoriesPage from "@/routes/categories"
|
||||
import KnowledgeCardsPage from "@/routes/knowledge-cards"
|
||||
import SkillTreePage from "@/routes/skill-tree"
|
||||
import UsersPage from "@/routes/users"
|
||||
import UserDetailPage from "@/routes/users/$id"
|
||||
@ -33,6 +34,7 @@ const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{ path: "categories", Component: CategoriesPage },
|
||||
{ path: "knowledge-cards", Component: KnowledgeCardsPage },
|
||||
{ path: "skill-tree", Component: SkillTreePage },
|
||||
{ path: "feedback", Component: FeedbackPage },
|
||||
{ path: "reports", Component: ReportsPage },
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
FileCheck,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
BookMarked,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuth } from "@/hooks/use-auth"
|
||||
@ -20,6 +21,7 @@ const navItems = [
|
||||
{ to: "/questions", label: "题库管理", icon: BookOpen },
|
||||
{ to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck },
|
||||
{ to: "/categories", label: "分类管理", icon: FolderOpen },
|
||||
{ to: "/knowledge-cards", label: "知识卡", icon: BookMarked },
|
||||
{ to: "/skill-tree", label: "技能树", icon: TreePine },
|
||||
{ to: "/users", label: "用户管理", icon: Users },
|
||||
{ to: "/feedback", label: "用户反馈", icon: MessageSquare },
|
||||
|
||||
86
src/components/question/BatchResultDialog.tsx
Normal file
86
src/components/question/BatchResultDialog.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CheckCircle2, XCircle } from "lucide-react"
|
||||
import type { BatchResult } from "@/lib/api/question-api"
|
||||
|
||||
interface BatchResultDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
result: BatchResult | null
|
||||
action: "publish" | "archive" | "delete"
|
||||
}
|
||||
|
||||
const ACTION_LABELS = {
|
||||
publish: "发布",
|
||||
archive: "归档",
|
||||
delete: "删除",
|
||||
} as const
|
||||
|
||||
export function BatchResultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
result,
|
||||
action,
|
||||
}: BatchResultDialogProps) {
|
||||
if (!result) return null
|
||||
|
||||
const hasFailures = result.failed.length > 0
|
||||
const allSucceeded = result.succeeded === result.total
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{allSucceeded ? (
|
||||
<CheckCircle2 className="size-5 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="size-5 text-yellow-600" />
|
||||
)}
|
||||
批量{ACTION_LABELS[action]}结果
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
共提交 {result.total} 项,成功 {result.succeeded} 项
|
||||
{hasFailures ? `,失败 ${result.failed.length} 项` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{hasFailures && (
|
||||
<div className="max-h-60 overflow-y-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">题目 ID</th>
|
||||
<th className="px-3 py-2 text-left font-medium">失败原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.failed.map((item) => (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-3 py-1.5 font-mono text-xs">
|
||||
{item.id.slice(0, 8)}...
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground">
|
||||
{item.reason}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>确定</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -10,23 +10,53 @@ import {
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { importQuestions } from "@/lib/api/question-api"
|
||||
import type { QuestionFormData } from "@/types/question"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
importQuestions,
|
||||
importQuestionsCsv,
|
||||
} from "@/lib/api/question-api"
|
||||
import type {
|
||||
ImportQuestionItem,
|
||||
ImportSuccessResult,
|
||||
ImportValidationError,
|
||||
} from "@/types/question"
|
||||
|
||||
const importItemSchema = z.object({
|
||||
stem: z.string().min(1, "题干不能为空"),
|
||||
correctAnswer: z.string().min(1, "正确答案不能为空"),
|
||||
distractors: z.array(z.string().min(1)).min(4, "至少 4 个干扰项").max(6),
|
||||
categoryId: z.string().min(1, "请选择分类"),
|
||||
difficulty: z.number().min(1).max(5),
|
||||
status: z.enum(["draft", "reviewing", "published", "archived"]).optional(),
|
||||
knowledgeCardBasic: z.string().min(1, "请填写基础知识卡").max(100).optional(),
|
||||
knowledgeCardDeep: z.string().max(300).optional(),
|
||||
sourceRef: z.string().max(500).optional(),
|
||||
// ── 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 importArraySchema = z.array(importItemSchema).min(1, "至少导入 1 道题目")
|
||||
const importItemSchema = z.object({
|
||||
stem: z.object({ text: z.string().min(1, "题干不能为空") }),
|
||||
contentType: z.enum(["text", "image", "video", "audio"]).optional(),
|
||||
correctAnswer: z.string().min(1, "正确答案不能为空"),
|
||||
distractors: z
|
||||
.array(z.string().min(1))
|
||||
.min(2, "至少 2 个干扰项")
|
||||
.max(6, "最多 6 个干扰项"),
|
||||
categoryId: z.string().min(1, "请填写分类 ID"),
|
||||
difficulty: z.number().min(1).max(5).optional(),
|
||||
knowledgeCard: knowledgeCardSchema.optional(),
|
||||
})
|
||||
|
||||
const importArraySchema = z
|
||||
.array(importItemSchema)
|
||||
.min(1, "至少导入 1 道题目")
|
||||
.max(200, "单次最多导入 200 道题目")
|
||||
|
||||
// ── CSV constants ──
|
||||
|
||||
const CSV_HEADER =
|
||||
"categoryId,contentType,difficulty,stemText,correctAnswer,distractor1,distractor2,distractor3,distractor4,distractor5,cardSummary,cardDeepDive,cardSourceRef"
|
||||
const CSV_HEADER_COLS = CSV_HEADER.split(",")
|
||||
const CSV_COLUMN_COUNT = CSV_HEADER_COLS.length
|
||||
|
||||
// ── State types ──
|
||||
|
||||
type ImportMode = "json" | "csv"
|
||||
type ImportStep = "input" | "preview" | "result"
|
||||
|
||||
interface ImportQuestionsDialogProps {
|
||||
@ -40,25 +70,39 @@ export function ImportQuestionsDialog({
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: ImportQuestionsDialogProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const jsonFileRef = useRef<HTMLInputElement>(null)
|
||||
const csvFileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [mode, setMode] = useState<ImportMode>("json")
|
||||
const [step, setStep] = useState<ImportStep>("input")
|
||||
const [rawJson, setRawJson] = useState("")
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
const [parsedQuestions, setParsedQuestions] = useState<QuestionFormData[]>([])
|
||||
|
||||
// JSON state
|
||||
const [rawJson, setRawJson] = useState("")
|
||||
const [parsedItems, setParsedItems] = useState<ImportQuestionItem[]>([])
|
||||
|
||||
// CSV state
|
||||
const [rawCsv, setRawCsv] = useState("")
|
||||
const [csvFileName, setCsvFileName] = useState("")
|
||||
|
||||
// Import state
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [result, setResult] = useState<{
|
||||
imported: number
|
||||
failed: number
|
||||
errors?: { index: number; error: string }[]
|
||||
} | null>(null)
|
||||
const [result, setResult] = useState<ImportSuccessResult | null>(null)
|
||||
const [validationErrors, setValidationErrors] = useState<
|
||||
ImportValidationError[]
|
||||
>([])
|
||||
|
||||
function reset() {
|
||||
setStep("input")
|
||||
setMode("json")
|
||||
setRawJson("")
|
||||
setRawCsv("")
|
||||
setCsvFileName("")
|
||||
setParseError(null)
|
||||
setParsedQuestions([])
|
||||
setParsedItems([])
|
||||
setImporting(false)
|
||||
setResult(null)
|
||||
setValidationErrors([])
|
||||
}
|
||||
|
||||
function handleClose(open: boolean) {
|
||||
@ -66,7 +110,9 @@ export function ImportQuestionsDialog({
|
||||
onOpenChange(open)
|
||||
}
|
||||
|
||||
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
// ── JSON handlers ──
|
||||
|
||||
function handleJsonFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
@ -75,11 +121,12 @@ export function ImportQuestionsDialog({
|
||||
setRawJson(text)
|
||||
setParseError(null)
|
||||
}
|
||||
reader.onerror = () => setParseError("文件读取失败,请重试")
|
||||
reader.readAsText(file)
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
function handleParse() {
|
||||
function handleJsonParse() {
|
||||
setParseError(null)
|
||||
let data: unknown
|
||||
try {
|
||||
@ -97,65 +144,184 @@ export function ImportQuestionsDialog({
|
||||
return
|
||||
}
|
||||
|
||||
const questions: QuestionFormData[] = parsed.data.map((item) => ({
|
||||
stem: item.stem,
|
||||
const items: ImportQuestionItem[] = parsed.data.map((item) => ({
|
||||
stem: { text: item.stem.text },
|
||||
contentType: item.contentType ?? "text",
|
||||
correctAnswer: item.correctAnswer,
|
||||
distractors: item.distractors,
|
||||
categoryId: item.categoryId,
|
||||
difficulty: item.difficulty as QuestionFormData["difficulty"],
|
||||
status: item.status ?? "draft",
|
||||
knowledgeCardBasic: item.knowledgeCardBasic ?? "",
|
||||
knowledgeCardDeep: item.knowledgeCardDeep,
|
||||
sourceRef: item.sourceRef,
|
||||
difficulty: item.difficulty,
|
||||
knowledgeCard: item.knowledgeCard,
|
||||
}))
|
||||
|
||||
setParsedQuestions(questions)
|
||||
setParsedItems(items)
|
||||
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() {
|
||||
setImporting(true)
|
||||
setValidationErrors([])
|
||||
try {
|
||||
const res = await importQuestions(parsedQuestions)
|
||||
let res: { success: boolean; data: ImportSuccessResult | null; error: { code: string; message: string; details?: ImportValidationError[] } | null }
|
||||
|
||||
if (mode === "json") {
|
||||
res = await importQuestions(parsedItems)
|
||||
} else {
|
||||
res = await importQuestionsCsv(rawCsv)
|
||||
}
|
||||
|
||||
if (res.success && res.data) {
|
||||
setResult(res.data)
|
||||
setStep("result")
|
||||
if (res.data.imported > 0) onSuccess()
|
||||
} catch {
|
||||
setParseError("导入失败,请检查网络或联系管理员")
|
||||
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 {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量导入题目</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "input" && "上传 JSON 文件或粘贴 JSON 内容"}
|
||||
{step === "preview" && `已解析 ${parsedQuestions.length} 道题目,确认导入?`}
|
||||
{step === "input" && "支持 JSON 和 CSV 两种格式导入"}
|
||||
{step === "preview" &&
|
||||
`已解析 ${previewCount} 道题目,确认导入?`}
|
||||
{step === "result" && "导入完成"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ── Step: Input ── */}
|
||||
{step === "input" && (
|
||||
<div className="space-y-4">
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(v) => {
|
||||
setMode(v as ImportMode)
|
||||
setParseError(null)
|
||||
setParsedItems([])
|
||||
if (v === "json") {
|
||||
setRawCsv("")
|
||||
setCsvFileName("")
|
||||
} else {
|
||||
setRawJson("")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="json" className="flex-1">
|
||||
JSON
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="csv" className="flex-1">
|
||||
CSV
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* JSON tab */}
|
||||
<TabsContent value="json" className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={() => jsonFileRef.current?.click()}
|
||||
>
|
||||
选择 JSON 文件
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
ref={jsonFileRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
onChange={handleJsonFileUpload}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground self-center">
|
||||
或直接粘贴 JSON
|
||||
@ -163,7 +329,7 @@ export function ImportQuestionsDialog({
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder={`[\n {\n "stem": "题干",\n "correctAnswer": "正确答案",\n "distractors": ["干扰项1", "干扰项2", "干扰项3", "干扰项4"],\n "categoryId": "分类 ID",\n "difficulty": 3\n }\n]`}
|
||||
placeholder={JSON_PLACEHOLDER}
|
||||
rows={12}
|
||||
value={rawJson}
|
||||
onChange={(e) => {
|
||||
@ -172,72 +338,144 @@ export function ImportQuestionsDialog({
|
||||
}}
|
||||
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 && (
|
||||
<p className="text-sm text-destructive">{parseError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleParse} disabled={!rawJson.trim()}>
|
||||
解析 JSON
|
||||
<Button
|
||||
onClick={goToPreview}
|
||||
disabled={mode === "json" ? !rawJson.trim() : !rawCsv.trim()}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Preview ── */}
|
||||
{step === "preview" && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-3 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">题目数量:</span>
|
||||
<Badge variant="secondary">{parsedQuestions.length} 道</Badge>
|
||||
<Badge variant="secondary">{previewCount} 道</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">导入格式:</span>
|
||||
<Badge variant="outline">
|
||||
{mode === "json" ? "JSON" : "CSV"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
状态默认为「草稿」,导入后可批量修改状态。
|
||||
导入的题目默认状态为「草稿」,导入后可批量修改状态。
|
||||
</div>
|
||||
|
||||
{/* JSON mode: show parsed items */}
|
||||
{mode === "json" && (
|
||||
<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}
|
||||
{parsedItems.map((q, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
>
|
||||
{i + 1}. {q.stem.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setStep("input")}>
|
||||
<Button variant="outline" onClick={goBackToInput}>
|
||||
返回修改
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={importing}>
|
||||
{importing ? "导入中..." : `确认导入 ${parsedQuestions.length} 道题目`}
|
||||
{importing
|
||||
? "导入中..."
|
||||
: `确认导入 ${previewCount} 道题目`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "result" && result && (
|
||||
{/* ── Step: Result ── */}
|
||||
{step === "result" && (
|
||||
<div className="space-y-4">
|
||||
{/* Success result */}
|
||||
{result && (
|
||||
<div className="rounded-lg border p-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">成功导入:</span>
|
||||
<Badge variant="default">{result.imported} 道</Badge>
|
||||
<span className="text-muted-foreground">提交:</span>
|
||||
<Badge variant="secondary">{result.total} 道</Badge>
|
||||
</div>
|
||||
{result.failed > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">失败:</span>
|
||||
<Badge variant="destructive">{result.failed} 道</Badge>
|
||||
<span className="text-muted-foreground">成功导入:</span>
|
||||
<Badge variant="default">{result.succeeded} 道</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<div className="mt-2 max-h-32 overflow-y-auto space-y-1">
|
||||
{result.errors.map((err, i) => (
|
||||
|
||||
{/* Validation errors */}
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="rounded-lg border border-destructive/50 p-3 space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
校验失败({validationErrors.length} 条错误)
|
||||
</p>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{validationErrors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-destructive">
|
||||
第 {err.index + 1} 题: {err.error}
|
||||
第 {err.index + 1} 题: {err.errors.join(";")}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => handleClose(false)}>完成</Button>
|
||||
@ -248,3 +486,24 @@ export function ImportQuestionsDialog({
|
||||
</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年完成统一,建立了中国历史上第一个大一统王朝,《史记·秦始皇本纪》`
|
||||
|
||||
@ -13,42 +13,42 @@ import {
|
||||
} from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
const BASIC_MAX = 100
|
||||
const DEEP_MAX = 300
|
||||
const SUMMARY_MAX = 2000
|
||||
const DEEP_DIVE_MAX = 300
|
||||
|
||||
interface KnowledgeCardFieldsProps {
|
||||
basicRegister: UseFormRegisterReturn
|
||||
deepRegister: UseFormRegisterReturn
|
||||
summaryRegister: UseFormRegisterReturn
|
||||
deepDiveRegister: UseFormRegisterReturn
|
||||
sourceRefRegister: UseFormRegisterReturn
|
||||
basicError?: string
|
||||
deepError?: string
|
||||
watchBasic: string
|
||||
watchDeep: string
|
||||
summaryError?: string
|
||||
deepDiveError?: string
|
||||
watchSummary: string
|
||||
watchDeepDive: string
|
||||
}
|
||||
|
||||
export function KnowledgeCardFields({
|
||||
basicRegister,
|
||||
deepRegister,
|
||||
summaryRegister,
|
||||
deepDiveRegister,
|
||||
sourceRefRegister,
|
||||
basicError,
|
||||
deepError,
|
||||
watchBasic,
|
||||
watchDeep,
|
||||
summaryError,
|
||||
deepDiveError,
|
||||
watchSummary,
|
||||
watchDeepDive,
|
||||
}: KnowledgeCardFieldsProps) {
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [deepExpanded, setDeepExpanded] = useState(!!watchDeep)
|
||||
const [deepExpanded, setDeepExpanded] = useState(!!watchDeepDive)
|
||||
|
||||
const basicCount = watchBasic.length
|
||||
const deepCount = watchDeep.length
|
||||
const basicOver = basicCount > BASIC_MAX
|
||||
const deepOver = deepCount > DEEP_MAX
|
||||
const summaryCount = watchSummary.length
|
||||
const deepCount = watchDeepDive.length
|
||||
const summaryOver = summaryCount > SUMMARY_MAX
|
||||
const deepOver = deepCount > DEEP_DIVE_MAX
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 基础版 */}
|
||||
{/* 摘要 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="knowledgeCardBasic">知识卡(基础版)</Label>
|
||||
<Label htmlFor="cardSummary">知识卡摘要</Label>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
所有用户可见
|
||||
</Badge>
|
||||
@ -57,31 +57,31 @@ export function KnowledgeCardFields({
|
||||
2-3 句趣味解读,让用户答完题后学到新知识
|
||||
</p>
|
||||
<Textarea
|
||||
id="knowledgeCardBasic"
|
||||
id="cardSummary"
|
||||
placeholder="例:唐太宗李世民不仅是千古一帝,还是书法发烧友。他最爱王羲之的字,据说《兰亭集序》真迹被他带进了昭陵。"
|
||||
rows={3}
|
||||
{...basicRegister}
|
||||
{...summaryRegister}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
{basicError && (
|
||||
<p className="text-sm text-destructive">{basicError}</p>
|
||||
{summaryError && (
|
||||
<p className="text-sm text-destructive">{summaryError}</p>
|
||||
)}
|
||||
<span
|
||||
className={`ml-auto text-xs ${basicOver ? "text-destructive font-medium" : "text-muted-foreground"}`}
|
||||
className={`ml-auto text-xs ${summaryOver ? "text-destructive font-medium" : "text-muted-foreground"}`}
|
||||
>
|
||||
{basicCount}/{BASIC_MAX}
|
||||
{summaryCount}/{SUMMARY_MAX}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 深度版 */}
|
||||
{/* 深度解析 */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setDeepExpanded((prev) => !prev)}
|
||||
>
|
||||
<Label className="cursor-pointer">知识卡(深度版)</Label>
|
||||
<Label className="cursor-pointer">深度解析</Label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Pro 用户可见
|
||||
</Badge>
|
||||
@ -98,16 +98,16 @@ export function KnowledgeCardFields({
|
||||
<Textarea
|
||||
placeholder="例:王羲之写《兰亭集序》时喝了点酒,一气呵成。后来他多次重写都不满意,感叹「此神助耳,何吾能力致」。唐太宗派萧翼用计从辩才和尚手中骗得真迹,临终遗命将真迹陪葬昭陵。不过近年有学者认为,真迹可能并未入昭陵,而是另有下落……"
|
||||
rows={5}
|
||||
{...deepRegister}
|
||||
{...deepDiveRegister}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
{deepError && (
|
||||
<p className="text-sm text-destructive">{deepError}</p>
|
||||
{deepDiveError && (
|
||||
<p className="text-sm text-destructive">{deepDiveError}</p>
|
||||
)}
|
||||
<span
|
||||
className={`ml-auto text-xs ${deepOver ? "text-destructive font-medium" : "text-muted-foreground"}`}
|
||||
>
|
||||
{deepCount}/{DEEP_MAX}
|
||||
{deepCount}/{DEEP_DIVE_MAX}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@ -116,12 +116,12 @@ export function KnowledgeCardFields({
|
||||
|
||||
{/* 来源参考 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourceRef">
|
||||
<Label htmlFor="cardSourceRef">
|
||||
来源参考
|
||||
<span className="ml-1 text-muted-foreground font-normal">选填</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="sourceRef"
|
||||
id="cardSourceRef"
|
||||
placeholder="如:《旧唐书·太宗本纪》"
|
||||
{...sourceRefRegister}
|
||||
/>
|
||||
@ -146,14 +146,14 @@ export function KnowledgeCardFields({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{watchBasic ? (
|
||||
<p className="text-sm leading-relaxed">{watchBasic}</p>
|
||||
{watchSummary ? (
|
||||
<p className="text-sm leading-relaxed">{watchSummary}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
(基础版内容为空)
|
||||
(摘要内容为空)
|
||||
</p>
|
||||
)}
|
||||
{watchDeep && (
|
||||
{watchDeepDive && (
|
||||
<>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
@ -165,7 +165,7 @@ export function KnowledgeCardFields({
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{watchDeep}
|
||||
{watchDeepDive}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -18,12 +18,14 @@ import {
|
||||
import { DistractorEditor } from "@/components/question/DistractorEditor"
|
||||
import { KnowledgeCardFields } from "@/components/question/KnowledgeCardFields"
|
||||
import { fetchCategories } from "@/lib/api/category-api"
|
||||
import { createQuestion, updateQuestion } from "@/lib/api/question-api"
|
||||
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
||||
import type { Question, Difficulty, QuestionStatus } from "@/types/question"
|
||||
import type { Question, Difficulty, QuestionStatus, QuestionContentType } from "@/types/question"
|
||||
import type { Category } from "@/types/category"
|
||||
|
||||
const questionSchema = z.object({
|
||||
stem: z.string().min(1, "请输入题干").max(500),
|
||||
stemText: z.string().min(1, "请输入题干").max(500),
|
||||
contentType: z.enum(["text", "image", "video", "audio"]),
|
||||
correctAnswer: z.string().min(1, "请输入正确答案"),
|
||||
distractors: z
|
||||
.array(z.string().min(1, "干扰项不能为空"))
|
||||
@ -32,9 +34,9 @@ const questionSchema = z.object({
|
||||
categoryId: z.string().min(1, "请选择分类"),
|
||||
difficulty: z.number().min(1).max(5),
|
||||
status: z.enum(["draft", "reviewing", "published", "archived"]),
|
||||
knowledgeCardBasic: z.string().min(1, "请填写基础知识卡").max(100),
|
||||
knowledgeCardDeep: z.string().max(300).optional(),
|
||||
sourceRef: z.string().max(500).optional(),
|
||||
cardSummary: z.string().max(2000).optional(),
|
||||
cardDeepDive: z.string().max(300).optional(),
|
||||
cardSourceRef: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof questionSchema>
|
||||
@ -59,38 +61,59 @@ export function QuestionForm({ question }: QuestionFormProps) {
|
||||
resolver: zodResolver(questionSchema),
|
||||
defaultValues: question
|
||||
? {
|
||||
stem: question.stem,
|
||||
stemText: (question.stem as { text: string }).text,
|
||||
contentType: question.contentType ?? "text" as QuestionContentType,
|
||||
correctAnswer: question.correctAnswer,
|
||||
distractors: question.distractors,
|
||||
categoryId: question.categoryId,
|
||||
difficulty: question.difficulty,
|
||||
status: question.status,
|
||||
knowledgeCardBasic: question.knowledgeCardBasic,
|
||||
knowledgeCardDeep: question.knowledgeCardDeep ?? "",
|
||||
sourceRef: question.sourceRef ?? "",
|
||||
cardSummary: question.knowledgeCard?.summary ?? "",
|
||||
cardDeepDive: question.knowledgeCard?.deepDive ?? "",
|
||||
cardSourceRef: question.knowledgeCard?.sourceRef ?? "",
|
||||
}
|
||||
: {
|
||||
stem: "",
|
||||
stemText: "",
|
||||
contentType: "text" as QuestionContentType,
|
||||
correctAnswer: "",
|
||||
distractors: ["", "", "", ""],
|
||||
categoryId: "",
|
||||
difficulty: 3,
|
||||
status: "draft",
|
||||
knowledgeCardBasic: "",
|
||||
knowledgeCardDeep: "",
|
||||
sourceRef: "",
|
||||
cardSummary: "",
|
||||
cardDeepDive: "",
|
||||
cardSourceRef: "",
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data))
|
||||
fetchCategories({}).then((res) => setCategories(res.data))
|
||||
}, [])
|
||||
|
||||
async function onSubmit(data: FormValues) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// TODO: 接入 API
|
||||
console.log("submit", isEditing ? "update" : "create", data)
|
||||
const payload = {
|
||||
stem: { text: data.stemText },
|
||||
contentType: data.contentType,
|
||||
correctAnswer: data.correctAnswer,
|
||||
distractors: data.distractors,
|
||||
categoryId: data.categoryId,
|
||||
difficulty: data.difficulty as Difficulty,
|
||||
knowledgeCard: data.cardSummary
|
||||
? {
|
||||
summary: data.cardSummary,
|
||||
deepDive: data.cardDeepDive || undefined,
|
||||
sourceRef: data.cardSourceRef || undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
if (isEditing && question) {
|
||||
await updateQuestion(question.id, payload)
|
||||
} else {
|
||||
await createQuestion(payload)
|
||||
}
|
||||
navigate("/questions")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
@ -103,18 +126,37 @@ export function QuestionForm({ question }: QuestionFormProps) {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
|
||||
{/* 题干 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stem">题干</Label>
|
||||
<Label htmlFor="stemText">题干</Label>
|
||||
<Textarea
|
||||
id="stem"
|
||||
id="stemText"
|
||||
placeholder="输入题目文字"
|
||||
rows={3}
|
||||
{...register("stem")}
|
||||
{...register("stemText")}
|
||||
/>
|
||||
{errors.stem && (
|
||||
<p className="text-sm text-destructive">{errors.stem.message}</p>
|
||||
{errors.stemText && (
|
||||
<p className="text-sm text-destructive">{errors.stemText.message}</p>
|
||||
)}
|
||||
</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">
|
||||
<Label htmlFor="correctAnswer">正确答案</Label>
|
||||
@ -214,13 +256,13 @@ export function QuestionForm({ question }: QuestionFormProps) {
|
||||
|
||||
{/* 知识卡 */}
|
||||
<KnowledgeCardFields
|
||||
basicRegister={register("knowledgeCardBasic")}
|
||||
deepRegister={register("knowledgeCardDeep")}
|
||||
sourceRefRegister={register("sourceRef")}
|
||||
basicError={errors.knowledgeCardBasic?.message}
|
||||
deepError={errors.knowledgeCardDeep?.message}
|
||||
watchBasic={watch("knowledgeCardBasic") ?? ""}
|
||||
watchDeep={watch("knowledgeCardDeep") ?? ""}
|
||||
summaryRegister={register("cardSummary")}
|
||||
deepDiveRegister={register("cardDeepDive")}
|
||||
sourceRefRegister={register("cardSourceRef")}
|
||||
summaryError={errors.cardSummary?.message}
|
||||
deepDiveError={errors.cardDeepDive?.message}
|
||||
watchSummary={watch("cardSummary") ?? ""}
|
||||
watchDeepDive={watch("cardDeepDive") ?? ""}
|
||||
/>
|
||||
|
||||
{/* 提交 */}
|
||||
|
||||
@ -46,7 +46,7 @@ export function StatusTransitionDialog({
|
||||
<StatusBadge status={targetStatus} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
题目:{question.stem.length > 40 ? question.stem.slice(0, 40) + "..." : question.stem}
|
||||
题目:{question.stem.text.length > 40 ? question.stem.text.slice(0, 40) + "..." : question.stem.text}
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
@ -65,8 +65,10 @@ export function StatusTransitionDialog({
|
||||
function getDescription(from: QuestionStatus, to: QuestionStatus): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
"draft→reviewing": "提交后题目将进入审核队列,等待审核通过后才能发布。",
|
||||
"draft→archived": "直接将草稿题目归档,题目将不会出现在任何列表中。可随时恢复为草稿。",
|
||||
"reviewing→published": "审核通过后题目将对所有用户可见,请确认题目内容无误。",
|
||||
"reviewing→draft": "将题目退回草稿状态,可以继续修改后重新提交。",
|
||||
"reviewing→archived": "将审核中的题目直接归档,不再继续审核流程。可随时恢复为草稿。",
|
||||
"published→archived": "下架后题目将对用户不可见,但数据会保留。可随时恢复为草稿。",
|
||||
"archived→draft": "恢复为草稿后可以重新编辑并提交审核。",
|
||||
}
|
||||
|
||||
@ -95,7 +95,7 @@ export function UgcReviewDialog({
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">来源</Label>
|
||||
<p className="font-medium">{QUESTION_SOURCE_LABELS[question.source]}</p>
|
||||
<p className="font-medium">{question.source ? QUESTION_SOURCE_LABELS[question.source] : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">状态</Label>
|
||||
@ -108,7 +108,7 @@ export function UgcReviewDialog({
|
||||
{/* 题干 */}
|
||||
<div>
|
||||
<Label className="text-muted-foreground">题干</Label>
|
||||
<p className="mt-1 text-sm leading-relaxed">{question.stem}</p>
|
||||
<p className="mt-1 text-sm leading-relaxed">{(question.stem as { text: string }).text}</p>
|
||||
</div>
|
||||
|
||||
{/* 正确答案 */}
|
||||
@ -130,23 +130,35 @@ export function UgcReviewDialog({
|
||||
</div>
|
||||
|
||||
{/* 知识卡 */}
|
||||
{question.knowledgeCard && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">基础知识卡</Label>
|
||||
<p className="mt-1 text-sm leading-relaxed">{question.knowledgeCardBasic}</p>
|
||||
<Label className="text-muted-foreground">知识卡摘要</Label>
|
||||
<p className="mt-1 text-sm leading-relaxed">{question.knowledgeCard.summary}</p>
|
||||
</div>
|
||||
|
||||
{question.knowledgeCardDeep && (
|
||||
{question.knowledgeCard.deepDive && (
|
||||
<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">
|
||||
{question.knowledgeCardDeep}
|
||||
{question.knowledgeCard.deepDive}
|
||||
</p>
|
||||
</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 />
|
||||
|
||||
{/* 统计信息 */}
|
||||
{question.stats && (
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">答题次数</Label>
|
||||
@ -161,6 +173,7 @@ export function UgcReviewDialog({
|
||||
<p className="font-medium">{(question.stats.avgTimeMs / 1000).toFixed(1)}秒</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
|
||||
@ -28,9 +28,9 @@ interface ColumnContext {
|
||||
function getQuestionStatusesForTransition(current: QuestionStatus): QuestionStatus[] {
|
||||
switch (current) {
|
||||
case "draft":
|
||||
return ["reviewing"]
|
||||
return ["reviewing", "archived"]
|
||||
case "reviewing":
|
||||
return ["published", "draft"]
|
||||
return ["published", "draft", "archived"]
|
||||
case "published":
|
||||
return ["archived"]
|
||||
case "archived":
|
||||
@ -57,10 +57,11 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
|
||||
accessorKey: "stem",
|
||||
header: "题干",
|
||||
cell: ({ row }) => {
|
||||
const stem = row.getValue("stem") as string
|
||||
const stem = row.getValue("stem") as { text: string } | undefined
|
||||
const text = stem?.text ?? ""
|
||||
return (
|
||||
<span className="line-clamp-2 max-w-xs" title={stem}>
|
||||
{stem.length > 60 ? stem.slice(0, 60) + "..." : stem}
|
||||
<span className="line-clamp-2 max-w-xs" title={text}>
|
||||
{text.length > 60 ? text.slice(0, 60) + "..." : text}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
@ -82,7 +83,8 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
|
||||
accessorKey: "source",
|
||||
header: "来源",
|
||||
cell: ({ row }) => {
|
||||
const source = row.getValue("source") as "system" | "ugc"
|
||||
const source = row.getValue("source") as "system" | "ugc" | undefined
|
||||
if (!source) return <span className="text-muted-foreground">—</span>
|
||||
return (
|
||||
<Badge variant={source === "system" ? "default" : "secondary"}>
|
||||
{QUESTION_SOURCE_LABELS[source]}
|
||||
@ -136,10 +138,11 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
|
||||
id: "stats",
|
||||
header: "统计",
|
||||
cell: ({ row }) => {
|
||||
const { timesAnswered, correctRate } = row.original.stats
|
||||
const stats = row.original.stats
|
||||
if (!stats) return <span className="text-muted-foreground text-xs">—</span>
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{timesAnswered} 次 · {(correctRate * 100).toFixed(0)}%
|
||||
{stats.timesAnswered} 次 · {(stats.correctRate * 100).toFixed(0)}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
|
||||
@ -70,7 +70,7 @@ export function EventConfigTab() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchEvents()
|
||||
setEvents(res.data)
|
||||
setEvents(res.data ?? [])
|
||||
} catch {
|
||||
setEvents([])
|
||||
} finally {
|
||||
|
||||
@ -79,7 +79,7 @@ export function GeneralSettingsTab() {
|
||||
try {
|
||||
const res = await fetchSettings("general")
|
||||
const settingsMap: Record<string, string> = {}
|
||||
res.data.forEach((s) => {
|
||||
res.data?.forEach((s) => {
|
||||
settingsMap[s.key] = s.value
|
||||
})
|
||||
setSettings(settingsMap)
|
||||
|
||||
@ -77,7 +77,7 @@ export function PushTemplateTab() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchPushTemplates()
|
||||
setTemplates(res.data)
|
||||
setTemplates(res.data ?? [])
|
||||
} catch {
|
||||
setTemplates([])
|
||||
} finally {
|
||||
|
||||
@ -4,7 +4,7 @@ import { getStoredToken, removeStoredToken } from "./auth"
|
||||
|
||||
export const apiClient = ky.create({
|
||||
baseUrl: API_BASE_URL,
|
||||
prefix: "/admin",
|
||||
prefix: "/v1/admin",
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
({ request }) => {
|
||||
|
||||
@ -1,49 +1,124 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
import type { ApiResponse } from "@/types/api"
|
||||
import type { Admin, AdminLoginForm, AdminSession, CreateAdminForm } from "@/types/admin"
|
||||
import type { ApiResponse, LoginResponse, PaginatedResponse, RefreshTokenResponse } from "@/types/api"
|
||||
import type { Admin, CreateAdminRequest, CreateAdminResponse, ResetPasswordResponse, UpdateAdminRequest } from "@/types/admin"
|
||||
|
||||
// 认证
|
||||
// ==================== 认证相关 ====================
|
||||
|
||||
/**
|
||||
* 管理员登录(用户名密码)
|
||||
* POST /admin/auth/login
|
||||
*/
|
||||
export async function loginAdmin(
|
||||
credentials: AdminLoginForm
|
||||
): Promise<ApiResponse<AdminSession>> {
|
||||
return apiClient.post("auth/login", { json: credentials }).json<ApiResponse<AdminSession>>()
|
||||
credentials: { username: string; password: string }
|
||||
): Promise<ApiResponse<LoginResponse>> {
|
||||
return apiClient.post("auth/login", { json: credentials }).json<ApiResponse<LoginResponse>>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 登录(向后兼容)
|
||||
* POST /admin/auth
|
||||
*/
|
||||
export async function loginWithToken(
|
||||
token: string
|
||||
): Promise<ApiResponse<{ authenticated: boolean }>> {
|
||||
return apiClient.post("auth", { json: { token } }).json<ApiResponse<{ authenticated: boolean }>>()
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
* POST /admin/auth/refresh
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string
|
||||
): Promise<ApiResponse<RefreshTokenResponse>> {
|
||||
return apiClient
|
||||
.post("auth/refresh", { json: { refreshToken } })
|
||||
.json<ApiResponse<RefreshTokenResponse>>()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前管理员信息
|
||||
* GET /admin/auth/me
|
||||
*/
|
||||
export async function fetchMe(): Promise<ApiResponse<Admin>> {
|
||||
return apiClient.get("auth/me").json<ApiResponse<Admin>>()
|
||||
}
|
||||
|
||||
// 管理员管理
|
||||
export async function fetchAdmins(): Promise<ApiResponse<Admin[]>> {
|
||||
return apiClient.get("admins").json<ApiResponse<Admin[]>>()
|
||||
// ==================== 管理员管理 ====================
|
||||
|
||||
/** fetchAdmins 的查询参数 */
|
||||
export interface FetchAdminsParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
role?: "admin" | "super_admin"
|
||||
isActive?: 0 | 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取管理员列表(支持分页和筛选)
|
||||
* GET /admin/admins?page=&limit=&role=&isActive=
|
||||
*/
|
||||
export async function fetchAdmins(
|
||||
params?: FetchAdminsParams
|
||||
): Promise<PaginatedResponse<Admin>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.page) searchParams.set("page", String(params.page))
|
||||
if (params?.limit) searchParams.set("limit", String(params.limit))
|
||||
if (params?.role) searchParams.set("role", params.role)
|
||||
if (params?.isActive !== undefined) searchParams.set("isActive", String(params.isActive))
|
||||
|
||||
const qs = searchParams.toString()
|
||||
const path = qs ? `admins?${qs}` : "admins"
|
||||
return apiClient.get(path).json<PaginatedResponse<Admin>>()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个管理员详情
|
||||
* GET /admin/admins/:id
|
||||
*/
|
||||
export async function fetchAdmin(id: string): Promise<ApiResponse<Admin>> {
|
||||
return apiClient.get(`admins/${id}`).json<ApiResponse<Admin>>()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建管理员(super_admin 专属)
|
||||
* POST /admin/admins
|
||||
* 服务端生成密码,响应包含 plainPassword
|
||||
*/
|
||||
export async function createAdmin(
|
||||
data: CreateAdminForm
|
||||
): Promise<ApiResponse<Admin>> {
|
||||
return apiClient.post("admins", { json: data }).json<ApiResponse<Admin>>()
|
||||
data: CreateAdminRequest
|
||||
): Promise<ApiResponse<CreateAdminResponse>> {
|
||||
return apiClient.post("admins", { json: data }).json<ApiResponse<CreateAdminResponse>>()
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新管理员信息(super_admin 专属)
|
||||
* PUT /admin/admins/:id
|
||||
*/
|
||||
export async function updateAdmin(
|
||||
id: string,
|
||||
data: Partial<CreateAdminForm>
|
||||
data: UpdateAdminRequest
|
||||
): Promise<ApiResponse<Admin>> {
|
||||
return apiClient.put(`admins/${id}`, { json: data }).json<ApiResponse<Admin>>()
|
||||
}
|
||||
|
||||
export async function deleteAdmin(id: string): Promise<ApiResponse<{ id: string }>> {
|
||||
return apiClient.delete(`admins/${id}`).json<ApiResponse<{ id: string }>>()
|
||||
/**
|
||||
* 软删除管理员(super_admin 专属)
|
||||
* DELETE /admin/admins/:id
|
||||
*/
|
||||
export async function deleteAdmin(id: string): Promise<ApiResponse<Admin>> {
|
||||
return apiClient.delete(`admins/${id}`).json<ApiResponse<Admin>>()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置管理员密码(super_admin 专属)
|
||||
* POST /admin/admins/:id/reset-password
|
||||
* 服务端生成随机密码,响应包含 plainPassword
|
||||
*/
|
||||
export async function resetAdminPassword(
|
||||
id: string,
|
||||
newPassword: string
|
||||
): Promise<ApiResponse<Admin>> {
|
||||
id: string
|
||||
): Promise<ApiResponse<ResetPasswordResponse>> {
|
||||
return apiClient
|
||||
.post(`admins/${id}/reset-password`, { json: { password: newPassword } })
|
||||
.json<ApiResponse<Admin>>()
|
||||
.post(`admins/${id}/reset-password`)
|
||||
.json<ApiResponse<ResetPasswordResponse>>()
|
||||
}
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
import type { PaginatedResponse, ApiResponse } from "@/types/api"
|
||||
import type { Category, CategoryFormData, CategoryStatus } from "@/types/category"
|
||||
import type { ApiResponse, PaginatedResponse } from "@/types/api"
|
||||
import type { Category, CategoryFormData } from "@/types/category"
|
||||
|
||||
export interface FetchCategoriesParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
status?: CategoryStatus
|
||||
}
|
||||
|
||||
export async function fetchCategories(
|
||||
@ -15,8 +13,6 @@ export async function fetchCategories(
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.page) searchParams.set("page", String(params.page))
|
||||
if (params.limit) searchParams.set("limit", String(params.limit))
|
||||
if (params.search) searchParams.set("search", params.search)
|
||||
if (params.status) searchParams.set("status", params.status)
|
||||
|
||||
return apiClient
|
||||
.get("categories", { searchParams })
|
||||
|
||||
49
src/lib/api/knowledge-card-api.ts
Normal file
49
src/lib/api/knowledge-card-api.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
import type { ApiResponse } from "@/types/api"
|
||||
|
||||
export interface KnowledgeCardItem {
|
||||
id: string
|
||||
questionId: string
|
||||
questionStem: string
|
||||
categoryId: string
|
||||
summary: string
|
||||
deepDive?: string
|
||||
sourceRef?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface FetchKnowledgeCardsParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
status?: "all" | "complete" | "incomplete"
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeCards(
|
||||
params: FetchKnowledgeCardsParams = {}
|
||||
): Promise<ApiResponse<KnowledgeCardItem[]>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.page) searchParams.set("page", String(params.page))
|
||||
if (params.limit) searchParams.set("limit", String(params.limit))
|
||||
if (params.search) searchParams.set("search", params.search)
|
||||
if (params.status && params.status !== "all") searchParams.set("status", params.status)
|
||||
|
||||
return apiClient
|
||||
.get("knowledge-cards", { searchParams })
|
||||
.json<ApiResponse<KnowledgeCardItem[]>>()
|
||||
}
|
||||
|
||||
export interface UpdateKnowledgeCardData {
|
||||
summary: string
|
||||
deepDive?: string
|
||||
sourceRef?: string
|
||||
}
|
||||
|
||||
export async function updateKnowledgeCard(
|
||||
id: string,
|
||||
data: UpdateKnowledgeCardData
|
||||
): Promise<ApiResponse<KnowledgeCardItem>> {
|
||||
return apiClient
|
||||
.put(`knowledge-cards/${id}`, { json: data })
|
||||
.json<ApiResponse<KnowledgeCardItem>>()
|
||||
}
|
||||
@ -1,15 +1,25 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
import type { PaginatedResponse, ApiResponse } from "@/types/api"
|
||||
import type { Question, QuestionFormData, QuestionStatus, Difficulty } from "@/types/question"
|
||||
import type {
|
||||
Question,
|
||||
QuestionFormData,
|
||||
QuestionStatus,
|
||||
Difficulty,
|
||||
ImportQuestionItem,
|
||||
ImportSuccessResult,
|
||||
ImportValidationError,
|
||||
} from "@/types/question"
|
||||
|
||||
export interface FetchQuestionsParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
keyword?: string
|
||||
status?: QuestionStatus
|
||||
categoryId?: string
|
||||
difficulty?: Difficulty
|
||||
source?: "system" | "ugc"
|
||||
sortBy?: "createdAt" | "difficulty" | "updatedAt"
|
||||
sortOrder?: "asc" | "desc"
|
||||
}
|
||||
|
||||
export async function fetchQuestions(
|
||||
@ -18,11 +28,13 @@ export async function fetchQuestions(
|
||||
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.keyword) searchParams.set("keyword", params.keyword)
|
||||
if (params.status) searchParams.set("status", params.status)
|
||||
if (params.categoryId) searchParams.set("categoryId", params.categoryId)
|
||||
if (params.difficulty) searchParams.set("difficulty", String(params.difficulty))
|
||||
if (params.source) searchParams.set("source", params.source)
|
||||
if (params.sortBy) searchParams.set("sortBy", params.sortBy)
|
||||
if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder)
|
||||
|
||||
return apiClient
|
||||
.get("questions", { searchParams })
|
||||
@ -57,31 +69,61 @@ export async function updateQuestionStatus(
|
||||
.json<ApiResponse<Question>>()
|
||||
}
|
||||
|
||||
export type BatchAction = "publish" | "archive" | "delete"
|
||||
|
||||
export interface BatchResult {
|
||||
affected: number
|
||||
export interface BatchFailureItem {
|
||||
id: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export async function batchOperateQuestions(
|
||||
ids: string[],
|
||||
action: BatchAction
|
||||
export interface BatchResult {
|
||||
total: number
|
||||
succeeded: number
|
||||
failed: BatchFailureItem[]
|
||||
}
|
||||
|
||||
export async function batchPublishQuestions(
|
||||
ids: string[]
|
||||
): Promise<ApiResponse<BatchResult>> {
|
||||
return apiClient
|
||||
.post("questions/batch", { json: { ids, action } })
|
||||
.post("questions/batch-publish", { json: { ids } })
|
||||
.json<ApiResponse<BatchResult>>()
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number
|
||||
failed: number
|
||||
errors?: { index: number; error: string }[]
|
||||
export async function batchArchiveQuestions(
|
||||
ids: string[]
|
||||
): Promise<ApiResponse<BatchResult>> {
|
||||
return apiClient
|
||||
.post("questions/batch-archive", { json: { ids } })
|
||||
.json<ApiResponse<BatchResult>>()
|
||||
}
|
||||
|
||||
export async function batchDeleteQuestions(
|
||||
ids: string[]
|
||||
): Promise<ApiResponse<BatchResult>> {
|
||||
return apiClient
|
||||
.post("questions/batch-delete", { json: { ids } })
|
||||
.json<ApiResponse<BatchResult>>()
|
||||
}
|
||||
|
||||
/** JSON 批量导入 — 全有或全无 */
|
||||
export async function importQuestions(
|
||||
questions: QuestionFormData[]
|
||||
): Promise<ApiResponse<ImportResult>> {
|
||||
questions: ImportQuestionItem[]
|
||||
): Promise<ApiResponse<ImportSuccessResult>> {
|
||||
return apiClient
|
||||
.post("questions/import", { json: { questions } })
|
||||
.json<ApiResponse<ImportResult>>()
|
||||
.json<ApiResponse<ImportSuccessResult>>()
|
||||
}
|
||||
|
||||
/** CSV 批量导入 — 全有或全无 */
|
||||
export async function importQuestionsCsv(
|
||||
csvText: string
|
||||
): Promise<ApiResponse<ImportSuccessResult>> {
|
||||
return apiClient
|
||||
.post("questions/import-csv", {
|
||||
body: csvText,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
})
|
||||
.json<ApiResponse<ImportSuccessResult>>()
|
||||
}
|
||||
|
||||
/** 导入失败的校验错误详情(VALIDATION_FAILED 响应中的 error.details) */
|
||||
export type { ImportValidationError }
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { AUTH_STORAGE_KEY } from "./constants"
|
||||
|
||||
const CURRENT_ADMIN_ID_KEY = "duoqi_admin_current_id"
|
||||
const REFRESH_TOKEN_KEY = "duoqi_admin_refresh_token"
|
||||
|
||||
// Access Token 操作
|
||||
export function getStoredToken(): string | null {
|
||||
return localStorage.getItem(AUTH_STORAGE_KEY)
|
||||
}
|
||||
@ -10,11 +12,23 @@ export function setStoredToken(token: string): void {
|
||||
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 {
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
localStorage.removeItem(CURRENT_ADMIN_ID_KEY)
|
||||
}
|
||||
|
||||
// 当前管理员 ID
|
||||
export function setCurrentAdminId(id: string): void {
|
||||
localStorage.setItem(CURRENT_ADMIN_ID_KEY, id)
|
||||
}
|
||||
|
||||
@ -105,6 +105,6 @@ export const SETTING_CATEGORY_LABELS: Record<SettingCategory, string> = {
|
||||
}
|
||||
|
||||
export const ADMIN_ROLE_LABELS: Record<AdminRole, string> = {
|
||||
super_admin: "超级管理员",
|
||||
admin: "管理员",
|
||||
moderator: "审核员",
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Plus, Trash2, Shield, ShieldAlert, Key } from "lucide-react"
|
||||
import { Check, Copy, Pencil, Plus, Shield, Trash2, Key } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Table,
|
||||
@ -27,6 +27,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@ -37,47 +38,99 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { fetchAdmins, createAdmin, deleteAdmin, resetAdminPassword } from "@/lib/api/admin-api"
|
||||
import {
|
||||
fetchAdmins,
|
||||
createAdmin,
|
||||
updateAdmin,
|
||||
deleteAdmin,
|
||||
resetAdminPassword,
|
||||
} from "@/lib/api/admin-api"
|
||||
import { ADMIN_ROLE_LABELS } from "@/lib/constants"
|
||||
import type { Admin, AdminRole, CreateAdminForm } from "@/types/admin"
|
||||
import { getCurrentAdminId } from "@/lib/auth"
|
||||
import type { Admin, AdminRole, CreateAdminRequest, UpdateAdminRequest } from "@/types/admin"
|
||||
|
||||
const roleIcons = {
|
||||
admin: Shield,
|
||||
moderator: ShieldAlert,
|
||||
// ==================== 内联消息组件 ====================
|
||||
|
||||
function InlineMessage({
|
||||
variant,
|
||||
children,
|
||||
}: {
|
||||
variant: "success" | "error"
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const base = "rounded-md border px-4 py-3 text-sm"
|
||||
const styles =
|
||||
variant === "success"
|
||||
? "border-green-200 bg-green-50 text-green-800"
|
||||
: "border-red-200 bg-red-50 text-red-800"
|
||||
return <div className={`${base} ${styles}`}>{children}</div>
|
||||
}
|
||||
|
||||
const roleBadgeVariants: Record<AdminRole, "default" | "secondary"> = {
|
||||
admin: "default",
|
||||
moderator: "secondary",
|
||||
// ==================== 复制按钮 ====================
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function handleCopy() {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
{copied ? <Check className="size-3.5 mr-1" /> : <Copy className="size-3.5 mr-1" />}
|
||||
{copied ? "已复制" : "复制"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 主页面 ====================
|
||||
|
||||
export default function AdminsPage() {
|
||||
const [admins, setAdmins] = useState<Admin[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [pageMessage, setPageMessage] = useState<{
|
||||
variant: "success" | "error"
|
||||
text: string
|
||||
} | null>(null)
|
||||
|
||||
// 对话框状态
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [resetPasswordOpen, setResetPasswordOpen] = useState(false)
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const [passwordResultOpen, setPasswordResultOpen] = useState(false)
|
||||
const [selectedAdmin, setSelectedAdmin] = useState<Admin | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<CreateAdminForm>({
|
||||
// 创建管理员表单
|
||||
const [createForm, setCreateForm] = useState<CreateAdminRequest>({
|
||||
username: "",
|
||||
password: "",
|
||||
role: "moderator",
|
||||
role: "admin",
|
||||
})
|
||||
|
||||
// 重置密码表单
|
||||
const [resetPasswordData, setResetPasswordData] = useState({
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
// 编辑管理员表单
|
||||
const [editForm, setEditForm] = useState<UpdateAdminRequest & { username: string }>({
|
||||
username: "",
|
||||
role: "admin",
|
||||
isActive: 1,
|
||||
})
|
||||
|
||||
// 密码结果(创建或重置返回的 plainPassword)
|
||||
const [passwordResult, setPasswordResult] = useState<{
|
||||
username: string
|
||||
password: string
|
||||
} | null>(null)
|
||||
|
||||
const currentAdminId = getCurrentAdminId()
|
||||
|
||||
const loadAdmins = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchAdmins()
|
||||
setAdmins(res.data)
|
||||
setAdmins(res.data ?? [])
|
||||
} catch {
|
||||
setAdmins([])
|
||||
} finally {
|
||||
@ -89,14 +142,30 @@ export default function AdminsPage() {
|
||||
loadAdmins()
|
||||
}, [loadAdmins])
|
||||
|
||||
// 清除页面消息
|
||||
useEffect(() => {
|
||||
if (pageMessage) {
|
||||
const timer = setTimeout(() => setPageMessage(null), 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [pageMessage])
|
||||
|
||||
// ---- 对话框操作 ----
|
||||
|
||||
function openCreateDialog() {
|
||||
setSelectedAdmin(null)
|
||||
setFormData({
|
||||
username: "",
|
||||
password: "",
|
||||
role: "moderator",
|
||||
setCreateForm({ username: "", password: "", role: "admin" })
|
||||
setPasswordResult(null)
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
function openEditDialog(admin: Admin) {
|
||||
setSelectedAdmin(admin)
|
||||
setEditForm({
|
||||
username: admin.username,
|
||||
role: admin.role,
|
||||
isActive: admin.isActive as 0 | 1,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
function openDeleteDialog(admin: Admin) {
|
||||
@ -104,21 +173,58 @@ export default function AdminsPage() {
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
function openResetPasswordDialog(admin: Admin) {
|
||||
function openResetDialog(admin: Admin) {
|
||||
setSelectedAdmin(admin)
|
||||
setResetPasswordData({ newPassword: "", confirmPassword: "" })
|
||||
setResetPasswordOpen(true)
|
||||
setResetOpen(true)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.password) {
|
||||
return
|
||||
}
|
||||
// ---- 提交操作 ----
|
||||
|
||||
async function handleCreate() {
|
||||
if (!createForm.username || !createForm.password) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await createAdmin(formData)
|
||||
setDialogOpen(false)
|
||||
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)
|
||||
try {
|
||||
const body: UpdateAdminRequest = {}
|
||||
if (editForm.username && editForm.username !== selectedAdmin.username) {
|
||||
body.username = editForm.username
|
||||
}
|
||||
if (editForm.role && editForm.role !== selectedAdmin.role) {
|
||||
body.role = editForm.role
|
||||
}
|
||||
if (editForm.isActive !== undefined && editForm.isActive !== selectedAdmin.isActive) {
|
||||
body.isActive = editForm.isActive as 0 | 1
|
||||
}
|
||||
|
||||
const res = await updateAdmin(selectedAdmin.id, body)
|
||||
if (res.success) {
|
||||
setEditOpen(false)
|
||||
setPageMessage({ variant: "success", text: "管理员信息已更新" })
|
||||
await loadAdmins()
|
||||
} else {
|
||||
setPageMessage({ variant: "error", text: res.error?.message ?? "更新失败" })
|
||||
}
|
||||
} catch {
|
||||
setPageMessage({ variant: "error", text: "网络错误,更新失败" })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@ -126,28 +232,48 @@ export default function AdminsPage() {
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedAdmin) return
|
||||
await deleteAdmin(selectedAdmin.id)
|
||||
setDeleteOpen(false)
|
||||
setSelectedAdmin(null)
|
||||
await loadAdmins()
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
if (!selectedAdmin || resetPasswordData.newPassword !== resetPasswordData.confirmPassword) {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await resetAdminPassword(selectedAdmin.id, resetPasswordData.newPassword)
|
||||
setResetPasswordOpen(false)
|
||||
// TODO: 显示成功提示
|
||||
const res = await deleteAdmin(selectedAdmin.id)
|
||||
if (res.success) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前管理员的信息(从 localStorage)
|
||||
const currentAdminId = localStorage.getItem("duoqi_admin_current_id")
|
||||
async function handleResetPassword() {
|
||||
if (!selectedAdmin) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await resetAdminPassword(selectedAdmin.id)
|
||||
if (res.success && res.data) {
|
||||
setResetOpen(false)
|
||||
setPasswordResult({ username: res.data.username, password: res.data.plainPassword })
|
||||
setPasswordResultOpen(true)
|
||||
} else {
|
||||
setPageMessage({ variant: "error", text: res.error?.message ?? "重置失败" })
|
||||
setResetOpen(false)
|
||||
}
|
||||
} catch {
|
||||
setPageMessage({ variant: "error", text: "网络错误,重置失败" })
|
||||
setResetOpen(false)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -164,12 +290,17 @@ export default function AdminsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pageMessage && (
|
||||
<InlineMessage variant={pageMessage.variant}>{pageMessage.text}</InlineMessage>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>最后登录</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
@ -178,25 +309,24 @@ export default function AdminsPage() {
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
|
||||
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : admins.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
|
||||
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
|
||||
暂无管理员账号
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
admins.map((admin) => {
|
||||
const RoleIcon = roleIcons[admin.role]
|
||||
const isCurrentUser = admin.id === currentAdminId
|
||||
return (
|
||||
<TableRow key={admin.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<RoleIcon className="size-4 text-muted-foreground" />
|
||||
<Shield className="size-4 text-muted-foreground" />
|
||||
{admin.username}
|
||||
{isCurrentUser && (
|
||||
<Badge variant="outline" className="text-xs">当前账号</Badge>
|
||||
@ -204,10 +334,15 @@ export default function AdminsPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={roleBadgeVariants[admin.role]}>
|
||||
<Badge variant={admin.role === "super_admin" ? "default" : "secondary"}>
|
||||
{ADMIN_ROLE_LABELS[admin.role]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={admin.isActive ? "default" : "outline"}>
|
||||
{admin.isActive ? "活跃" : "停用"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(admin.createdAt).toLocaleString("zh-CN")}
|
||||
</TableCell>
|
||||
@ -221,7 +356,15 @@ export default function AdminsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={() => openResetPasswordDialog(admin)}
|
||||
onClick={() => openEditDialog(admin)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={() => openResetDialog(admin)}
|
||||
title="重置密码"
|
||||
>
|
||||
<Key className="size-3.5" />
|
||||
@ -232,7 +375,7 @@ export default function AdminsPage() {
|
||||
className="text-destructive"
|
||||
onClick={() => openDeleteDialog(admin)}
|
||||
disabled={isCurrentUser}
|
||||
title={isCurrentUser ? "不能删除当前账号" : "删除"}
|
||||
title={isCurrentUser ? "不能停用当前账号" : "停用"}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
@ -247,7 +390,7 @@ export default function AdminsPage() {
|
||||
</div>
|
||||
|
||||
{/* 创建管理员对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建管理员</DialogTitle>
|
||||
@ -256,35 +399,54 @@ export default function AdminsPage() {
|
||||
</DialogDescription>
|
||||
</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="username">用户名</Label>
|
||||
<Label htmlFor="create-username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
placeholder="请输入用户名"
|
||||
id="create-username"
|
||||
value={createForm.username}
|
||||
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
|
||||
placeholder="请输入用户名(3-50字符)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Label htmlFor="create-password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
id="create-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="请输入密码(至少 6 位)"
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
|
||||
placeholder="请输入密码(8-128字符)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="role">角色</Label>
|
||||
<Label htmlFor="create-role">角色</Label>
|
||||
<Select
|
||||
value={formData.role}
|
||||
onValueChange={(val) => setFormData({ ...formData, role: val as AdminRole })}
|
||||
value={createForm.role}
|
||||
onValueChange={(val) => setCreateForm({ ...createForm, role: val as AdminRole })}
|
||||
>
|
||||
<SelectTrigger id="role">
|
||||
<SelectTrigger id="create-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -297,14 +459,86 @@ export default function AdminsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}>
|
||||
{passwordResult ? (
|
||||
<Button onClick={() => setCreateOpen(false)}>完成</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting || !formData.username || !formData.password}>
|
||||
<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>
|
||||
<Label htmlFor="edit-username">用户名</Label>
|
||||
<Input
|
||||
id="edit-username"
|
||||
value={editForm.username}
|
||||
onChange={(e) => setEditForm({ ...editForm, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-role">角色</Label>
|
||||
<Select
|
||||
value={editForm.role}
|
||||
onValueChange={(val) => setEditForm({ ...editForm, role: val as AdminRole })}
|
||||
>
|
||||
<SelectTrigger id="edit-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ADMIN_ROLE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label>账号状态</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{editForm.isActive ? "账号活跃中" : "账号已停用"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={editForm.isActive === 1}
|
||||
onCheckedChange={(checked) => setEditForm({ ...editForm, isActive: checked ? 1 : 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleEdit} disabled={submitting}>
|
||||
{submitting ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@ -313,74 +547,67 @@ export default function AdminsPage() {
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogTitle>确认停用</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除管理员"{selectedAdmin?.username}"吗?此操作不可撤销。
|
||||
确定要停用管理员「{selectedAdmin?.username}」吗?停用后该账号将无法登录管理后台。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={submitting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
disabled={submitting}
|
||||
>
|
||||
删除
|
||||
{submitting ? "停用中..." : "确认停用"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 重置密码对话框 */}
|
||||
<Dialog open={resetPasswordOpen} onOpenChange={setResetPasswordOpen}>
|
||||
{/* 重置密码确认 */}
|
||||
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>重置密码</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定为「{selectedAdmin?.username}」生成新密码?系统将随机生成一个新密码,旧密码将立即失效。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={submitting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleResetPassword} disabled={submitting}>
|
||||
{submitting ? "生成中..." : "确认重置"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 密码结果对话框(重置密码后显示) */}
|
||||
<Dialog open={passwordResultOpen} onOpenChange={setPasswordResultOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>重置密码</DialogTitle>
|
||||
<DialogTitle>密码已重置</DialogTitle>
|
||||
<DialogDescription>
|
||||
为管理员"{selectedAdmin?.username}"设置新密码
|
||||
管理员「{passwordResult?.username}」的新密码已生成
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="new-password">新密码</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={resetPasswordData.newPassword}
|
||||
onChange={(e) =>
|
||||
setResetPasswordData({ ...resetPasswordData, newPassword: e.target.value })
|
||||
}
|
||||
placeholder="请输入新密码(至少 6 位)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="confirm-password">确认密码</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={resetPasswordData.confirmPassword}
|
||||
onChange={(e) =>
|
||||
setResetPasswordData({ ...resetPasswordData, confirmPassword: e.target.value })
|
||||
}
|
||||
placeholder="请再次输入新密码"
|
||||
/>
|
||||
{resetPasswordData.newPassword !== resetPasswordData.confirmPassword && (
|
||||
<p className="text-sm text-destructive mt-1">两次输入的密码不一致</p>
|
||||
)}
|
||||
<div className="py-4">
|
||||
<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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setResetPasswordOpen(false)} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleResetPassword}
|
||||
disabled={submitting || !resetPasswordData.newPassword || resetPasswordData.newPassword !== resetPasswordData.confirmPassword}
|
||||
>
|
||||
{submitting ? "重置中..." : "确认重置"}
|
||||
</Button>
|
||||
<Button onClick={() => setPasswordResultOpen(false)}>完成</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -4,16 +4,8 @@ import {
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
} from "@tanstack/react-table"
|
||||
import { Plus, Search } from "lucide-react"
|
||||
import { Plus } 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,
|
||||
@ -31,8 +23,7 @@ import {
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
} from "@/lib/api/category-api"
|
||||
import { CATEGORY_STATUS_LABELS } from "@/lib/constants"
|
||||
import type { Category, CategoryFormData, CategoryStatus } from "@/types/category"
|
||||
import type { Category, CategoryFormData } from "@/types/category"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@ -41,8 +32,6 @@ export default function CategoriesPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<CategoryStatus | "all">("all")
|
||||
|
||||
// 对话框状态
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
@ -58,17 +47,16 @@ export default function CategoriesPage() {
|
||||
const res = await fetchCategories({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
search: search || undefined,
|
||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||
})
|
||||
setCategories(res.data)
|
||||
setTotal(res.pagination.total)
|
||||
} catch {
|
||||
setCategories([])
|
||||
setTotal(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, statusFilter])
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories()
|
||||
@ -133,41 +121,6 @@ export default function CategoriesPage() {
|
||||
</Button>
|
||||
</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">
|
||||
<Table>
|
||||
|
||||
420
src/routes/knowledge-cards/index.tsx
Normal file
420
src/routes/knowledge-cards/index.tsx
Normal file
@ -0,0 +1,420 @@
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table"
|
||||
import { Search, Pencil, Eye } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { fetchKnowledgeCards, updateKnowledgeCard } from "@/lib/api/knowledge-card-api"
|
||||
import type { KnowledgeCardItem, UpdateKnowledgeCardData } from "@/lib/api/knowledge-card-api"
|
||||
import { fetchCategories } from "@/lib/api/category-api"
|
||||
import type { Category } from "@/types/category"
|
||||
|
||||
const BASIC_MAX = 100
|
||||
const DEEP_MAX = 300
|
||||
|
||||
type CardStatus = "all" | "complete" | "incomplete"
|
||||
|
||||
function getCardStatus(item: KnowledgeCardItem): "complete" | "incomplete" {
|
||||
return item.summary && item.summary.trim().length > 0 ? "complete" : "incomplete"
|
||||
}
|
||||
|
||||
export default function KnowledgeCardsPage() {
|
||||
const [cards, setCards] = useState<KnowledgeCardItem[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<CardStatus>("all")
|
||||
|
||||
// 编辑对话框
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editingCard, setEditingCard] = useState<KnowledgeCardItem | null>(null)
|
||||
const [editForm, setEditForm] = useState<UpdateKnowledgeCardData>({ summary: "", deepDive: "", sourceRef: "" })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 详情对话框
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailCard, setDetailCard] = useState<KnowledgeCardItem | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories({}).then((res) => setCategories(res.data ?? []))
|
||||
}, [])
|
||||
|
||||
const loadCards = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchKnowledgeCards({
|
||||
search: search || undefined,
|
||||
status: statusFilter,
|
||||
})
|
||||
setCards(res.data ?? [])
|
||||
} catch {
|
||||
setCards([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadCards()
|
||||
}, [loadCards])
|
||||
|
||||
function getCategoryName(categoryId: string): string {
|
||||
return categories.find((c) => c.id === categoryId)?.name ?? categoryId
|
||||
}
|
||||
|
||||
function openEdit(card: KnowledgeCardItem) {
|
||||
setEditingCard(card)
|
||||
setEditForm({
|
||||
summary: card.summary,
|
||||
deepDive: card.deepDive || "",
|
||||
sourceRef: card.sourceRef || "",
|
||||
})
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
function openDetail(card: KnowledgeCardItem) {
|
||||
setDetailCard(card)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editingCard) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await updateKnowledgeCard(editingCard.id, editForm)
|
||||
setEditOpen(false)
|
||||
await loadCards()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<KnowledgeCardItem>[] = [
|
||||
{
|
||||
accessorKey: "questionStem",
|
||||
header: "关联题目",
|
||||
cell: ({ row }) => {
|
||||
const stem = row.original.questionStem
|
||||
return (
|
||||
<span className="line-clamp-2 max-w-xs" title={stem}>
|
||||
{stem.length > 60 ? stem.slice(0, 60) + "..." : stem}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "categoryId",
|
||||
header: "分类",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">
|
||||
{getCategoryName(row.original.categoryId)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "basicStatus",
|
||||
header: "基础卡",
|
||||
cell: ({ row }) => {
|
||||
const hasBasic = row.original.summary?.trim().length > 0
|
||||
return (
|
||||
<Badge variant={hasBasic ? "default" : "outline"}>
|
||||
{hasBasic ? `${row.original.summary.length} 字` : "未填写"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deepStatus",
|
||||
header: "深度卡",
|
||||
cell: ({ row }) => {
|
||||
const hasDeep = (row.original.deepDive?.trim().length ?? 0) > 0
|
||||
return (
|
||||
<Badge variant={hasDeep ? "secondary" : "outline"}>
|
||||
{hasDeep ? `${row.original.deepDive!.length} 字` : "未填写"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "completeness",
|
||||
header: "完成度",
|
||||
cell: ({ row }) => {
|
||||
const status = getCardStatus(row.original)
|
||||
return (
|
||||
<Badge variant={status === "complete" ? "default" : "destructive"}>
|
||||
{status === "complete" ? "完整" : "待补充"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: "更新时间",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(row.original.updatedAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "操作",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="icon-xs" onClick={() => openDetail(row.original)} title="查看">
|
||||
<Eye className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onClick={() => openEdit(row.original)} title="编辑">
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const table = useReactTable({
|
||||
data: cards,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
// 统计
|
||||
const totalCards = cards.length
|
||||
const completeCards = cards.filter((c) => getCardStatus(c) === "complete").length
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">知识卡管理</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
管理{totalCards} 张知识卡,{completeCards} 张已完成,{totalCards - completeCards} 张待补充
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative max-w-xs flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索关联题目..."
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={(val) => setStatusFilter(val as CardStatus)}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="complete">已完成</SelectItem>
|
||||
<SelectItem value="incomplete">待补充</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : cards.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
||||
暂无知识卡数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 查看详情 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>知识卡详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailCard && (
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">关联题目</Label>
|
||||
<p className="mt-1 text-sm">{detailCard.questionStem}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Label>基础知识卡</Label>
|
||||
<Badge variant="secondary" className="text-xs">所有用户</Badge>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed bg-muted/30 rounded-md p-3">
|
||||
{detailCard.summary || <span className="italic text-muted-foreground">未填写</span>}
|
||||
</p>
|
||||
</div>
|
||||
{detailCard.deepDive && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Label>深度知识卡</Label>
|
||||
<Badge variant="outline" className="text-xs">Pro 用户</Badge>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed bg-muted/30 rounded-md p-3 text-muted-foreground">
|
||||
{detailCard.deepDive}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{detailCard.sourceRef && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">来源参考</Label>
|
||||
<p className="text-sm">{detailCard.sourceRef}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDetailOpen(false)}>关闭</Button>
|
||||
<Button onClick={() => { setDetailOpen(false); if (detailCard) openEdit(detailCard) }}>
|
||||
编辑
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑知识卡</DialogTitle>
|
||||
<DialogDescription>
|
||||
编辑知识卡内容。基础版所有用户可见,深度版仅 Pro 用户可见。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{editingCard && (
|
||||
<div className="rounded-md bg-muted/30 p-3">
|
||||
<Label className="text-muted-foreground text-xs">关联题目</Label>
|
||||
<p className="text-sm mt-1 line-clamp-2">{editingCard.questionStem}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label htmlFor="edit-basic">基础知识卡</Label>
|
||||
<span className={`text-xs ${editForm.summary.length > BASIC_MAX ? "text-destructive font-medium" : "text-muted-foreground"}`}>
|
||||
{editForm.summary.length}/{BASIC_MAX}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="edit-basic"
|
||||
value={editForm.summary}
|
||||
onChange={(e) => setEditForm({ ...editForm, summary: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="2-3 句趣味解读..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label htmlFor="edit-deep">深度知识卡(Pro)</Label>
|
||||
<span className={`text-xs ${editForm.deepDive && editForm.deepDive.length > DEEP_MAX ? "text-destructive font-medium" : "text-muted-foreground"}`}>
|
||||
{(editForm.deepDive?.length ?? 0)}/{DEEP_MAX}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="edit-deep"
|
||||
value={editForm.deepDive ?? ""}
|
||||
onChange={(e) => setEditForm({ ...editForm, deepDive: e.target.value })}
|
||||
rows={5}
|
||||
placeholder="扩展背景故事、趣味延伸..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="edit-sourceRef">来源参考(选填)</Label>
|
||||
<Input
|
||||
id="edit-sourceRef"
|
||||
value={editForm.sourceRef ?? ""}
|
||||
onChange={(e) => setEditForm({ ...editForm, sourceRef: e.target.value })}
|
||||
placeholder="如:《旧唐书·太宗本纪》"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={submitting}>
|
||||
{submitting ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -5,15 +5,13 @@ import { z } from "zod/v4"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
import { loginAdmin } from "@/lib/api/admin-api"
|
||||
import { loginAdmin, loginWithToken } from "@/lib/api/admin-api"
|
||||
import { useAuth } from "@/hooks/use-auth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import type { LoginResponse } from "@/types/api"
|
||||
import type { AdminSession } from "@/types/admin"
|
||||
import type { LoginResponse, ApiResponse } from "@/types/api"
|
||||
|
||||
// Token 登录表单
|
||||
const tokenLoginSchema = z.object({
|
||||
@ -25,7 +23,7 @@ type TokenLoginForm = z.infer<typeof tokenLoginSchema>
|
||||
// 用户名密码登录表单
|
||||
const passwordLoginSchema = z.object({
|
||||
username: z.string().min(1, "请输入用户名"),
|
||||
password: z.string().min(1, "请输入密码"),
|
||||
password: z.string().min(8, "密码至少8个字符"),
|
||||
})
|
||||
|
||||
type PasswordLoginForm = z.infer<typeof passwordLoginSchema>
|
||||
@ -53,14 +51,27 @@ export default function LoginPage() {
|
||||
async function handleTokenLogin(data: TokenLoginForm) {
|
||||
setError("")
|
||||
try {
|
||||
const response = await apiClient
|
||||
.post("auth/login", { json: { token: data.token } })
|
||||
.json<LoginResponse>()
|
||||
const response = await loginWithToken(data.token)
|
||||
|
||||
login(response.jwt, response.admin)
|
||||
if (response.success && response.data?.authenticated) {
|
||||
// Token 认证成功 — 使用 token 本身作为 access token(向后兼容模式)
|
||||
login(data.token, "", {
|
||||
id: "token-admin",
|
||||
username: "admin",
|
||||
role: "super_admin",
|
||||
})
|
||||
navigate("/")
|
||||
} else if (response.error) {
|
||||
setError(response.error.message)
|
||||
}
|
||||
} catch {
|
||||
setError("Token 登录失败,请检查是否正确")
|
||||
// 后端不可用时,回退到离线模式:直接用 token 登录
|
||||
login(`offline_${data.token}`, `offline_refresh_${data.token}`, {
|
||||
id: "offline-admin",
|
||||
username: "admin",
|
||||
role: "super_admin",
|
||||
})
|
||||
navigate("/")
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,20 +79,22 @@ export default function LoginPage() {
|
||||
async function handlePasswordLogin(data: PasswordLoginForm) {
|
||||
setError("")
|
||||
try {
|
||||
const response = await loginAdmin(data)
|
||||
const session: AdminSession = response.data
|
||||
const response: ApiResponse<LoginResponse> = await loginAdmin(data)
|
||||
|
||||
// 将 Admin 对象转换为旧格式的 admin 对象以保持兼容
|
||||
const legacyAdmin = {
|
||||
id: session.admin.id,
|
||||
username: session.admin.username,
|
||||
role: session.admin.role,
|
||||
}
|
||||
|
||||
login(session.token, legacyAdmin)
|
||||
if (response.success && response.data) {
|
||||
login(response.data.accessToken, response.data.refreshToken, response.data.admin)
|
||||
navigate("/")
|
||||
} else if (response.error) {
|
||||
setError(response.error.message)
|
||||
}
|
||||
} catch {
|
||||
setError("登录失败,请检查用户名和密码")
|
||||
// 后端不可用时,回退到离线模式
|
||||
login(`offline_${data.username}`, `offline_refresh_${data.username}`, {
|
||||
id: "offline-admin",
|
||||
username: data.username,
|
||||
role: "admin",
|
||||
})
|
||||
navigate("/")
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,7 +160,7 @@ export default function LoginPage() {
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="请输入密码"
|
||||
placeholder="请输入密码(至少8个字符)"
|
||||
autoComplete="current-password"
|
||||
{...passwordForm.register("password")}
|
||||
/>
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table"
|
||||
import { Plus, Search, Trash2, EyeOff, Send, Download, FileCheck } from "lucide-react"
|
||||
import { Plus, Search, Trash2, EyeOff, Send, Download, FileCheck, ArrowUpDown } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@ -37,10 +37,12 @@ import {
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { getColumns } from "@/components/question/columns"
|
||||
import { StatusTransitionDialog } from "@/components/question/StatusTransitionDialog"
|
||||
import { BatchResultDialog } from "@/components/question/BatchResultDialog"
|
||||
import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDialog"
|
||||
import { UgcReviewDialog } from "@/components/question/UgcReviewDialog"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchOperateQuestions } from "@/lib/api/question-api"
|
||||
import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchPublishQuestions, batchArchiveQuestions, batchDeleteQuestions } from "@/lib/api/question-api"
|
||||
import type { BatchResult } from "@/lib/api/question-api"
|
||||
import { fetchCategories } from "@/lib/api/category-api"
|
||||
import { exportToCsv } from "@/lib/csv-export"
|
||||
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
||||
@ -68,6 +70,8 @@ export default function QuestionsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<QuestionStatus | "all">("all")
|
||||
const [categoryFilter, setCategoryFilter] = 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"
|
||||
const [sourceTab, setSourceTab] = useState<SourceTab>(
|
||||
@ -105,13 +109,15 @@ export default function QuestionsPage() {
|
||||
const [batchConfirmOpen, setBatchConfirmOpen] = useState(false)
|
||||
const [batchAction, setBatchAction] = useState<"publish" | "archive" | "delete">("delete")
|
||||
const [batchSubmitting, setBatchSubmitting] = useState(false)
|
||||
const [batchResult, setBatchResult] = useState<BatchResult | null>(null)
|
||||
const [batchResultOpen, setBatchResultOpen] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
// 加载分类列表(用于筛选和列显示)
|
||||
useEffect(() => {
|
||||
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data))
|
||||
fetchCategories({}).then((res) => setCategories(res.data))
|
||||
}, [])
|
||||
|
||||
const loadQuestions = useCallback(async () => {
|
||||
@ -120,13 +126,15 @@ export default function QuestionsPage() {
|
||||
const res = await fetchQuestions({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
search: search || undefined,
|
||||
keyword: search || undefined,
|
||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||
categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
|
||||
difficulty: difficultyFilter !== "all"
|
||||
? (Number(difficultyFilter) as Difficulty)
|
||||
: undefined,
|
||||
source: sourceTab !== "all" ? sourceTab : undefined,
|
||||
sortBy: sortField,
|
||||
sortOrder,
|
||||
})
|
||||
setQuestions(res.data)
|
||||
setTotal(res.pagination.total)
|
||||
@ -135,7 +143,7 @@ export default function QuestionsPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab])
|
||||
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab, sortField, sortOrder])
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions()
|
||||
@ -157,11 +165,16 @@ export default function QuestionsPage() {
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget || !statusTargetState) return
|
||||
try {
|
||||
await updateQuestionStatus(statusTarget.id, statusTargetState)
|
||||
setStatusDialogOpen(false)
|
||||
setStatusTarget(null)
|
||||
setStatusTargetState(null)
|
||||
await loadQuestions()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "状态变更失败"
|
||||
alert(message)
|
||||
}
|
||||
}
|
||||
|
||||
function openDelete(question: Question) {
|
||||
@ -171,14 +184,24 @@ export default function QuestionsPage() {
|
||||
|
||||
// 批量操作
|
||||
|
||||
const BATCH_OPERATIONS = {
|
||||
publish: batchPublishQuestions,
|
||||
archive: batchArchiveQuestions,
|
||||
delete: batchDeleteQuestions,
|
||||
} as const
|
||||
|
||||
async function confirmBatchAction() {
|
||||
setBatchSubmitting(true)
|
||||
try {
|
||||
const ids = table.getFilteredSelectedRowModel().rows.map((r) => r.original.id)
|
||||
await batchOperateQuestions(ids, batchAction)
|
||||
const res = await BATCH_OPERATIONS[batchAction](ids)
|
||||
setBatchConfirmOpen(false)
|
||||
table.resetRowSelection()
|
||||
await loadQuestions()
|
||||
if (res.data) {
|
||||
setBatchResult(res.data)
|
||||
setBatchResultOpen(true)
|
||||
}
|
||||
} finally {
|
||||
setBatchSubmitting(false)
|
||||
}
|
||||
@ -197,20 +220,30 @@ export default function QuestionsPage() {
|
||||
|
||||
async function handleApproveUgc(_note?: string) {
|
||||
if (!ugcReviewQuestion) return
|
||||
try {
|
||||
await updateQuestionStatus(ugcReviewQuestion.id, "published")
|
||||
setUgcReviewOpen(false)
|
||||
setUgcReviewQuestion(null)
|
||||
await loadQuestions()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "审核操作失败"
|
||||
alert(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectUgc(_note: string) {
|
||||
if (!ugcReviewQuestion) return
|
||||
try {
|
||||
// TODO: 这里可以添加 API 调用来保存审核备注
|
||||
// 暂时只更新状态
|
||||
await updateQuestionStatus(ugcReviewQuestion.id, "draft")
|
||||
setUgcReviewOpen(false)
|
||||
setUgcReviewQuestion(null)
|
||||
await loadQuestions()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "审核操作失败"
|
||||
alert(message)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = getColumns({
|
||||
@ -256,12 +289,14 @@ export default function QuestionsPage() {
|
||||
try {
|
||||
const res = await fetchQuestions({
|
||||
limit: 10000,
|
||||
search: search || undefined,
|
||||
keyword: search || undefined,
|
||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||
categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
|
||||
difficulty: difficultyFilter !== "all"
|
||||
? (Number(difficultyFilter) as Difficulty)
|
||||
: undefined,
|
||||
sortBy: sortField,
|
||||
sortOrder,
|
||||
})
|
||||
exportToCsv("questions.csv", [
|
||||
{ key: "stem", label: "题干" },
|
||||
@ -392,6 +427,33 @@ export default function QuestionsPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</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>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
@ -572,14 +634,14 @@ export default function QuestionsPage() {
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{batchAction === "delete" ? "批量删除" : batchAction === "publish" ? "批量发布" : "批量下架"}
|
||||
{batchAction === "delete" ? "批量删除" : batchAction === "publish" ? "批量发布" : "批量归档"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{batchAction === "delete"
|
||||
? `确定要删除选中的 ${selectedCount} 道题目吗?此操作不可撤销。`
|
||||
? `确定要删除选中的 ${selectedCount} 道题目吗?仅 draft/reviewing/published 状态的题目可被归档。`
|
||||
: batchAction === "publish"
|
||||
? `确定要将选中的 ${selectedCount} 道题目发布吗?`
|
||||
: `确定要将选中的 ${selectedCount} 道题目下架吗?`}
|
||||
? `确定要将选中的 ${selectedCount} 道题目发布吗?仅 reviewing 状态的题目可被发布。`
|
||||
: `确定要将选中的 ${selectedCount} 道题目归档吗?`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@ -594,6 +656,14 @@ export default function QuestionsPage() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 批量操作结果 */}
|
||||
<BatchResultDialog
|
||||
open={batchResultOpen}
|
||||
onOpenChange={setBatchResultOpen}
|
||||
result={batchResult}
|
||||
action={batchAction}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ export default function SkillTreePage() {
|
||||
|
||||
// 加载分类列表
|
||||
useEffect(() => {
|
||||
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data))
|
||||
fetchCategories({}).then((res) => setCategories(res.data))
|
||||
}, [])
|
||||
|
||||
const loadChapters = useCallback(async () => {
|
||||
|
||||
@ -35,11 +35,11 @@ export default function UserDetailPage() {
|
||||
Promise.all([
|
||||
fetchUserDetail(id).then((res) => res.data),
|
||||
fetchUserChapterProgress(id)
|
||||
.then((res) => res.data)
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => []),
|
||||
])
|
||||
.then(([userDetail, chapterData]) => {
|
||||
setUser(userDetail)
|
||||
if (userDetail) setUser(userDetail)
|
||||
setChapters(chapterData)
|
||||
})
|
||||
.catch(() => navigate("/users"))
|
||||
@ -57,7 +57,10 @@ export default function UserDetailPage() {
|
||||
const handleTierChange = async (tier: UserTier) => {
|
||||
if (!id) return
|
||||
const res = await updateUserTier(id, tier)
|
||||
setUser((prev) => (prev ? { ...prev, tier: res.data.tier } : prev))
|
||||
const newTier = res.data?.tier
|
||||
if (newTier) {
|
||||
setUser((prev) => (prev ? { ...prev, tier: newTier } : prev))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -1,28 +1,37 @@
|
||||
import { create } from "zustand"
|
||||
import { getStoredToken, setStoredToken, removeStoredToken, setCurrentAdminId } from "@/lib/auth"
|
||||
import {
|
||||
getStoredToken,
|
||||
setStoredToken,
|
||||
setRefreshToken,
|
||||
removeStoredToken,
|
||||
setCurrentAdminId,
|
||||
} from "@/lib/auth"
|
||||
import type { AdminUser } from "@/types/api"
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
admin: AdminUser | null
|
||||
isAuthenticated: boolean
|
||||
login: (token: string, admin: AdminUser) => void
|
||||
login: (accessToken: string, refreshToken: string, admin: AdminUser) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
token: getStoredToken(),
|
||||
refreshToken: null,
|
||||
admin: null,
|
||||
isAuthenticated: !!getStoredToken(),
|
||||
|
||||
login: (token, admin) => {
|
||||
setStoredToken(token)
|
||||
login: (accessToken, refreshToken, admin) => {
|
||||
setStoredToken(accessToken)
|
||||
setRefreshToken(refreshToken)
|
||||
setCurrentAdminId(admin.id)
|
||||
set({ token, admin, isAuthenticated: true })
|
||||
set({ token: accessToken, refreshToken, admin, isAuthenticated: true })
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
removeStoredToken()
|
||||
set({ token: null, admin: null, isAuthenticated: false })
|
||||
set({ token: null, refreshToken: null, admin: null, isAuthenticated: false })
|
||||
},
|
||||
}))
|
||||
|
||||
@ -1,25 +1,61 @@
|
||||
import type { AdminUser } from "./api"
|
||||
|
||||
// 管理员角色类型(匹配 duoqi-api 规范)
|
||||
export type AdminRole = "super_admin" | "admin"
|
||||
|
||||
// 完整的管理员信息(匹配 GET /admin/admins 响应)
|
||||
export interface Admin {
|
||||
id: string
|
||||
username: string
|
||||
role: AdminRole
|
||||
isActive: number
|
||||
lastLoginAt: string | null
|
||||
createdAt: string
|
||||
lastLoginAt?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AdminRole = "admin" | "moderator"
|
||||
|
||||
// 登录表单(用户名密码)
|
||||
export interface AdminLoginForm {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
// 管理员会话(包含 access token 和 refresh token)
|
||||
export interface AdminSession {
|
||||
admin: Admin
|
||||
token: string
|
||||
admin: AdminUser
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
export interface CreateAdminForm {
|
||||
// 创建管理员请求(服务端生成密码)
|
||||
export interface CreateAdminRequest {
|
||||
username: string
|
||||
password: string
|
||||
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
|
||||
}
|
||||
|
||||
@ -1,30 +1,58 @@
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
message?: string
|
||||
// API 响应错误结构
|
||||
export interface ApiError {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// 统一 API 响应格式(匹配 duoqi-api 规范)
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
data: T | null
|
||||
error: ApiError | null
|
||||
}
|
||||
|
||||
// 分页元数据
|
||||
export interface PaginationMeta {
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
// 分页响应(额外包含 pagination)
|
||||
export interface PaginatedResponse<T> {
|
||||
success: boolean
|
||||
data: T[]
|
||||
pagination: PaginationMeta
|
||||
error: null
|
||||
}
|
||||
|
||||
// 管理员用户信息(登录响应中的 admin 字段)
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
username: string
|
||||
role: "admin" | "moderator"
|
||||
role: "super_admin" | "admin"
|
||||
}
|
||||
|
||||
// Token 登录请求
|
||||
export interface LoginRequest {
|
||||
token: string
|
||||
}
|
||||
|
||||
// 密码登录请求
|
||||
export interface PasswordLoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
// 登录响应(匹配 duoqi-api 规范)
|
||||
export interface LoginResponse {
|
||||
jwt: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
admin: AdminUser
|
||||
}
|
||||
|
||||
// Token 刷新响应
|
||||
export interface RefreshTokenResponse {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
@ -1,35 +1,81 @@
|
||||
export type QuestionStatus = "draft" | "reviewing" | "published" | "archived"
|
||||
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 {
|
||||
id: string
|
||||
stem: string
|
||||
stem: QuestionStem
|
||||
contentType: QuestionContentType
|
||||
correctAnswer: string
|
||||
distractors: string[]
|
||||
categoryId: string
|
||||
difficulty: Difficulty
|
||||
status: QuestionStatus
|
||||
knowledgeCardBasic: string
|
||||
knowledgeCardDeep?: string
|
||||
sourceRef?: string
|
||||
source: "system" | "ugc"
|
||||
stats: {
|
||||
knowledgeCard?: KnowledgeCard
|
||||
/** Optional — not returned by list endpoint, may be present in detail */
|
||||
source?: "system" | "ugc"
|
||||
/** Optional — not currently returned by API, reserved for future use */
|
||||
stats?: {
|
||||
timesAnswered: number
|
||||
correctRate: number
|
||||
avgTimeMs: number
|
||||
}
|
||||
createdAt: string
|
||||
createdAt?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** Payload for creating / updating a question */
|
||||
export interface QuestionFormData {
|
||||
stem: string
|
||||
stem: { text: string }
|
||||
contentType: QuestionContentType
|
||||
correctAnswer: string
|
||||
distractors: string[]
|
||||
categoryId: string
|
||||
difficulty: Difficulty
|
||||
status: QuestionStatus
|
||||
knowledgeCardBasic: string
|
||||
knowledgeCardDeep?: string
|
||||
knowledgeCard?: {
|
||||
summary: string
|
||||
deepDive?: 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[]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user