feat: 完善题目状态流转 UI(Phase 1b)
- 新建 StatusTransitionDialog 确认对话框,显示流转方向和操作说明 - 状态列增加快速操作按钮(主流转),下拉菜单保留全部流转路径 - 新增 TRANSITION_LABELS 常量定义各状态的流转动作标签
This commit is contained in:
parent
9314dc8505
commit
a5025e633e
74
src/components/question/StatusTransitionDialog.tsx
Normal file
74
src/components/question/StatusTransitionDialog.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { StatusBadge } from "@/components/question/StatusBadge"
|
||||||
|
import { TRANSITION_LABELS } from "@/lib/constants"
|
||||||
|
import type { Question, QuestionStatus } from "@/types/question"
|
||||||
|
|
||||||
|
interface StatusTransitionDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
question: Question | null
|
||||||
|
targetStatus: QuestionStatus | null
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusTransitionDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
question,
|
||||||
|
targetStatus,
|
||||||
|
onConfirm,
|
||||||
|
}: StatusTransitionDialogProps) {
|
||||||
|
if (!question || !targetStatus) return null
|
||||||
|
|
||||||
|
const label = TRANSITION_LABELS[targetStatus]
|
||||||
|
const description = getDescription(question.status, targetStatus)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{label.title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p>{description}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={question.status} />
|
||||||
|
<span className="text-muted-foreground">→</span>
|
||||||
|
<StatusBadge status={targetStatus} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
题目:{question.stem.length > 40 ? question.stem.slice(0, 40) + "..." : question.stem}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirm}>
|
||||||
|
{label.action}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDescription(from: QuestionStatus, to: QuestionStatus): string {
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
"draft→reviewing": "提交后题目将进入审核队列,等待审核通过后才能发布。",
|
||||||
|
"reviewing→published": "审核通过后题目将对所有用户可见,请确认题目内容无误。",
|
||||||
|
"reviewing→draft": "将题目退回草稿状态,可以继续修改后重新提交。",
|
||||||
|
"published→archived": "下架后题目将对用户不可见,但数据会保留。可随时恢复为草稿。",
|
||||||
|
"archived→draft": "恢复为草稿后可以重新编辑并提交审核。",
|
||||||
|
}
|
||||||
|
return descriptions[`${from}→${to}`] ?? `确定要将题目状态从「${from}」改为「${to}」吗?`
|
||||||
|
}
|
||||||
@ -14,7 +14,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { StatusBadge } from "@/components/question/StatusBadge"
|
import { StatusBadge } from "@/components/question/StatusBadge"
|
||||||
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
import { DIFFICULTY_LABELS, QUESTION_STATUSES, TRANSITION_LABELS } from "@/lib/constants"
|
||||||
import type { Question, QuestionStatus } from "@/types/question"
|
import type { Question, QuestionStatus } from "@/types/question"
|
||||||
import type { Category } from "@/types/category"
|
import type { Category } from "@/types/category"
|
||||||
|
|
||||||
@ -37,6 +37,19 @@ function getQuestionStatusesForTransition(current: QuestionStatus): QuestionStat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPrimaryTransition(current: QuestionStatus): QuestionStatus | null {
|
||||||
|
switch (current) {
|
||||||
|
case "draft":
|
||||||
|
return "reviewing"
|
||||||
|
case "reviewing":
|
||||||
|
return "published"
|
||||||
|
case "published":
|
||||||
|
return "archived"
|
||||||
|
case "archived":
|
||||||
|
return "draft"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
|
export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -79,7 +92,24 @@ export function getColumns(ctx: ColumnContext): ColumnDef<Question>[] {
|
|||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: "状态",
|
header: "状态",
|
||||||
cell: ({ row }) => <StatusBadge status={row.getValue("status") as QuestionStatus} />,
|
cell: ({ row }) => {
|
||||||
|
const status = row.getValue("status") as QuestionStatus
|
||||||
|
const primary = getPrimaryTransition(status)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
{primary && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline"
|
||||||
|
onClick={() => ctx.onStatusChange(row.original, primary)}
|
||||||
|
>
|
||||||
|
{TRANSITION_LABELS[primary].title}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "distractors",
|
accessorKey: "distractors",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { CategoryStatus } from "@/types/category"
|
import type { CategoryStatus } from "@/types/category"
|
||||||
|
import type { QuestionStatus } from "@/types/question"
|
||||||
|
|
||||||
export const QUESTION_STATUSES = {
|
export const QUESTION_STATUSES = {
|
||||||
draft: "草稿",
|
draft: "草稿",
|
||||||
@ -7,6 +8,13 @@ export const QUESTION_STATUSES = {
|
|||||||
archived: "已下架",
|
archived: "已下架",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
export const TRANSITION_LABELS: Record<QuestionStatus, { title: string; action: string }> = {
|
||||||
|
draft: { title: "退回草稿", action: "确认退回" },
|
||||||
|
reviewing: { title: "提交审核", action: "确认提交" },
|
||||||
|
published: { title: "发布题目", action: "确认发布" },
|
||||||
|
archived: { title: "下架题目", action: "确认下架" },
|
||||||
|
}
|
||||||
|
|
||||||
export const DIFFICULTY_LABELS: Record<number, string> = {
|
export const DIFFICULTY_LABELS: Record<number, string> = {
|
||||||
1: "入门",
|
1: "入门",
|
||||||
2: "简单",
|
2: "简单",
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} 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 { fetchQuestions, deleteQuestion, updateQuestionStatus } from "@/lib/api/question-api"
|
import { fetchQuestions, deleteQuestion, updateQuestionStatus } from "@/lib/api/question-api"
|
||||||
import { fetchCategories } from "@/lib/api/category-api"
|
import { fetchCategories } from "@/lib/api/category-api"
|
||||||
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
||||||
@ -57,6 +58,11 @@ export default function QuestionsPage() {
|
|||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
const [deletingQuestion, setDeletingQuestion] = useState<Question | null>(null)
|
const [deletingQuestion, setDeletingQuestion] = useState<Question | null>(null)
|
||||||
|
|
||||||
|
// 状态流转对话框
|
||||||
|
const [statusDialogOpen, setStatusDialogOpen] = useState(false)
|
||||||
|
const [statusTarget, setStatusTarget] = useState<Question | null>(null)
|
||||||
|
const [statusTargetState, setStatusTargetState] = useState<QuestionStatus | null>(null)
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
// 加载分类列表(用于筛选和列显示)
|
// 加载分类列表(用于筛选和列显示)
|
||||||
@ -99,7 +105,17 @@ export default function QuestionsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleStatusChange(question: Question, status: QuestionStatus) {
|
async function handleStatusChange(question: Question, status: QuestionStatus) {
|
||||||
await updateQuestionStatus(question.id, status)
|
setStatusTarget(question)
|
||||||
|
setStatusTargetState(status)
|
||||||
|
setStatusDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmStatusChange() {
|
||||||
|
if (!statusTarget || !statusTargetState) return
|
||||||
|
await updateQuestionStatus(statusTarget.id, statusTargetState)
|
||||||
|
setStatusDialogOpen(false)
|
||||||
|
setStatusTarget(null)
|
||||||
|
setStatusTargetState(null)
|
||||||
await loadQuestions()
|
await loadQuestions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,6 +328,15 @@ export default function QuestionsPage() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 状态流转确认 */}
|
||||||
|
<StatusTransitionDialog
|
||||||
|
open={statusDialogOpen}
|
||||||
|
onOpenChange={setStatusDialogOpen}
|
||||||
|
question={statusTarget}
|
||||||
|
targetStatus={statusTargetState}
|
||||||
|
onConfirm={confirmStatusChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user