Files
Edu/apps/portal-shell/src/features/admin/questions/question-detail-dialog.tsx
SpecialX f991bf0446 feat(portal-shell): 管理域全模块功能补齐与差异修复
按 ARCHITECTURE.md 与 admin-NeedTodo.md 要求补齐所有管理页面缺失功能:
- users/roles/permissions:权限矩阵搜索/折叠、zod 校验、value 字段
- audit-logs:行内详情对话框、分页页码、ChartCardShell
- school:CRUD 对话框、GradeOverviewCards、academic-year 侧栏
- announcements/invitation-codes/ai-settings:发布按钮、分页、zod 校验
- course-plans/elective:Select 导入、undefined 处理
- error-book/scheduling/questions/lesson-plans/attendance:统计卡片

验证:typecheck 0 错误、arch:scan 已更新
2026-07-30 17:49:49 +08:00

308 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* 题目详情对话框ARCHITECTURE.md §5.4 / §9.4 / §10 P5
*
* 数据契约:
* - adminQuestion(id):❌ schema 无 → MSW 兜底(@contract-pending
*
* 适配 portal-shell
* - 用原生轻量模态fixed inset-0 + bg-black/50 + 卡片)替代 shadcn Dialog
* - 数据通过 useAdminQuestion hook 拉取
* - 错误处理走 notify.error()
*
* 关联ARCHITECTURE.md §5.3 契约纪律 / §9.4 / §11.3 DoD
*/
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import {
BookOpen,
Calendar,
FileText,
Hash,
HelpCircle,
Lightbulb,
RefreshCw,
Target,
User,
} from "lucide-react";
import { useAdminQuestion } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Separator } from "@/shared/components/ui/separator";
import {
difficultyToColorClass,
formatDifficulty,
formatQuestionDate,
formatQuestionStatus,
formatQuestionType,
questionStatusToBadgeClass,
} from "@/features/admin/questions/transformations";
export interface QuestionDetailDialogProps {
/** 当前选中的题目 id为空时关闭对话框 */
questionId: string | null;
onOpenChange: (open: boolean) => void;
}
/**
* 题目详情对话框。questionId 非空时打开,按 id 拉取详情。
*/
export function QuestionDetailDialog({
questionId,
onOpenChange,
}: QuestionDetailDialogProps): React.ReactElement {
const t = useTranslations("admin.questions.detailDialog");
const tCommon = useTranslations("common");
const open = questionId !== null && questionId.length > 0;
const { data, loading, error, refetch } = useAdminQuestion(questionId ?? "", {
enabled: open,
});
// 查询失败时通知用户§11.3 DoD #8catch 块必须包含 notify.error()
useEffect(() => {
if (error) {
notify.error(tCommon("error.loadFailed", { message: String(error) }));
}
}, [error, tCommon]);
// ESC 键关闭
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onOpenChange]);
if (!open) return <></>;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="question-detail-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => onOpenChange(false)}
>
<div
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border bg-card shadow-lg"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-start justify-between border-b p-6 pb-4">
<div className="flex-1">
<h2
id="question-detail-title"
className="flex items-center gap-2 text-lg font-semibold"
>
<HelpCircle className="size-5" />
{t("title")}
</h2>
{data ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline">{formatQuestionType(data.type)}</Badge>
<Badge variant="secondary">
{formatQuestionStatus(data.status)}
</Badge>
{data.subjectName ? (
<span className="text-muted-foreground">
{data.subjectName}
</span>
) : null}
</div>
) : null}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
aria-label={t("close")}
>
</Button>
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="space-y-3">
<div className="h-4 w-1/3 animate-pulse rounded bg-muted" />
<div className="h-20 animate-pulse rounded bg-muted" />
<div className="h-4 w-1/4 animate-pulse rounded bg-muted" />
<div className="h-16 animate-pulse rounded bg-muted" />
</div>
) : error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => void refetch()}
>
<RefreshCw className="size-4" />
{t("retry")}
</Button>
</div>
) : data ? (
<div className="space-y-5">
{/* 元信息 */}
<section className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
<InfoItem
icon={<Hash className="size-4" />}
label={t("type")}
value={formatQuestionType(data.type)}
/>
<InfoItem
icon={<Target className="size-4" />}
label={t("difficulty")}
value={
<span
className={`text-xs font-medium ${difficultyToColorClass(data.difficulty)}`}
>
{formatDifficulty(data.difficulty)}
</span>
}
/>
<InfoItem
icon={<FileText className="size-4" />}
label={t("status")}
value={
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${questionStatusToBadgeClass(data.status)}`}
>
{formatQuestionStatus(data.status)}
</span>
}
/>
<InfoItem
icon={<BookOpen className="size-4" />}
label={t("subject")}
value={data.subjectName || data.subjectId || "--"}
/>
<InfoItem
icon={<BookOpen className="size-4" />}
label={t("textbook")}
value={data.textbookTitle || data.textbookId || "--"}
/>
<InfoItem
icon={<Lightbulb className="size-4" />}
label={t("knowledgePoint")}
value={
data.knowledgePointTitle || data.knowledgePointId || "--"
}
/>
</section>
<Separator />
{/* 题目内容 */}
<section>
<h3 className="mb-2 text-sm font-medium">{t("content")}</h3>
<div className="rounded-md border bg-muted/30 p-3 text-sm">
<pre className="whitespace-pre-wrap break-words font-sans">
{data.content || t("noContent")}
</pre>
</div>
</section>
{/* 正确答案 */}
{data.answer ? (
<section>
<h3 className="mb-2 flex items-center gap-1 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<Target className="size-4" />
{t("answer")}
</h3>
<div className="rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
<pre className="whitespace-pre-wrap break-words font-sans">
{data.answer}
</pre>
</div>
</section>
) : null}
{/* 解析 */}
{data.explanation ? (
<section>
<h3 className="mb-2 flex items-center gap-1 text-sm font-medium">
<Lightbulb className="size-4" />
{t("explanation")}
</h3>
<div className="rounded-md border bg-background p-3 text-sm">
<pre className="whitespace-pre-wrap break-words font-sans">
{data.explanation}
</pre>
</div>
</section>
) : null}
{/* 来源 */}
{data.source ? (
<section>
<h3 className="mb-2 text-sm font-medium">{t("source")}</h3>
<p className="text-sm text-muted-foreground">{data.source}</p>
</section>
) : null}
{/* 元信息 */}
<section className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<User className="size-3" />
{t("createdBy")}{data.createdBy || "--"}
</span>
<span className="flex items-center gap-1">
<Calendar className="size-3" />
{t("createdAt")}{formatQuestionDate(data.createdAt)}
</span>
<span className="flex items-center gap-1">
<Calendar className="size-3" />
{t("updatedAt")}{formatQuestionDate(data.updatedAt)}
</span>
</section>
</div>
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
{t("noData")}
</div>
)}
</div>
{/* 底部 */}
<div className="flex justify-end gap-2 border-t p-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("close")}
</Button>
</div>
</div>
</div>
);
}
/** 信息项(图标 + 标签 + 值)。 */
function InfoItem({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
}): React.ReactElement {
return (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{icon}</span>
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="font-medium">{value || "--"}</p>
</div>
</div>
);
}