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