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 已更新
This commit is contained in:
SpecialX
2026-07-30 17:49:49 +08:00
parent 071542b757
commit f991bf0446
98 changed files with 25956 additions and 2140 deletions

View File

@@ -0,0 +1,408 @@
"use client";
/**
* 班级邀请码管理 - 客户端组件ARCHITECTURE.md §7.3 / §9.4 / §10 P5
*
* 数据契约:
* - useClassInvitationCodes / useGenerateClassInvitationCode / useRevokeClassInvitationCode
* ❌ schema 无对应根字段 → MSW 兜底(@contract-pending
*
* 功能:
* - 列出班级所有邀请码(含状态/有效期/使用次数)
* - 生成自定义邀请码(可选有效期/次数/备注)
* - 撤销邀请码(软删除)
* - 复制邀请码到剪贴板
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { useState } from "react";
import { Ban, Copy, Plus } from "lucide-react";
import { useTranslations } from "next-intl";
import {
useGenerateClassInvitationCode,
useRevokeClassInvitationCode,
useClassInvitationCodes,
type ClassInvitationCode,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { FormField } from "@/features/admin/school/schools-client";
// ── ClassInvitationManagerDialog ────────────────────────────────
/**
* 班级邀请码管理对话框。
*
* - 列表查询useClassInvitationCodes(classId)
* - 生成useGenerateClassInvitationCode自定义有效期/次数/备注
* - 撤销useRevokeClassInvitationCode软删除
* - 复制navigator.clipboard.writeText
*
* 子对话框GenerateCodeDialog生成新邀请码
*/
export function ClassInvitationManagerDialog({
open,
classId,
className,
onClose,
}: {
open: boolean;
classId: string;
className: string;
onClose: () => void;
}): React.ReactElement | null {
const t = useTranslations("admin.school.classes.invitation");
const tCommon = useTranslations("common");
const {
data: codes,
loading,
error,
refetch,
} = useClassInvitationCodes(classId);
const revokeMutation = useRevokeClassInvitationCode();
const [generateOpen, setGenerateOpen] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<ClassInvitationCode | null>(
null,
);
const handleCopy = async (code: string): Promise<void> => {
try {
await navigator.clipboard.writeText(code);
notify.success(t("copied"));
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleRevoke = async (): Promise<void> => {
if (!revokeTarget) return;
try {
await revokeMutation.run(revokeTarget.id);
notify.success(t("revokeSuccess"));
setRevokeTarget(null);
void refetch();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleGenerated = (): void => {
void refetch();
};
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="flex max-h-[90vh] w-full max-w-3xl flex-col rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">{t("title")}</h2>
{className ? (
<p className="mt-1 text-xs text-muted-foreground">
{t("classLabel")}: {className}
</p>
) : null}
</div>
<Button size="sm" onClick={() => setGenerateOpen(true)}>
<Plus className="mr-1.5 size-4" />
{t("generate")}
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="py-12 text-center text-sm text-muted-foreground">
{tCommon("loading")}
</div>
) : error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : codes && codes.length > 0 ? (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("colCode")}
</th>
<th className="p-3 text-left font-medium">
{t("colStatus")}
</th>
<th className="p-3 text-left font-medium">
{t("colUsedCount")}
</th>
<th className="p-3 text-left font-medium">
{t("colExpiresAt")}
</th>
<th className="p-3 text-left font-medium">
{t("colNote")}
</th>
<th className="p-3 text-right font-medium">
{t("colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{codes.map((record) => (
<tr key={record.id} className="hover:bg-muted/30">
<td className="p-3 font-mono font-medium tracking-wider">
{record.code}
</td>
<td className="p-3">
<InvitationStatusBadge status={record.status} />
</td>
<td className="p-3 text-sm">
{record.usedCount}
{record.maxUses !== null ? ` / ${record.maxUses}` : ""}
</td>
<td className="p-3 text-sm text-muted-foreground">
{record.expiresAt
? new Date(record.expiresAt).toLocaleString()
: t("neverExpires")}
</td>
<td className="max-w-[200px] truncate p-3 text-sm text-muted-foreground">
{record.note ?? "-"}
</td>
<td className="p-3 text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => void handleCopy(record.code)}
aria-label={t("copy")}
>
<Copy className="size-4" />
</Button>
{record.status === "active" ? (
<Button
variant="ghost"
size="sm"
onClick={() => setRevokeTarget(record)}
aria-label={t("revoke")}
>
<Ban className="size-4" />
</Button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-12 text-center text-sm text-muted-foreground">
{t("empty")}
</div>
)}
</div>
<div className="mt-4 flex justify-end">
<Button variant="outline" onClick={onClose}>
{tCommon("form.close")}
</Button>
</div>
<GenerateCodeDialog
open={generateOpen}
classId={classId}
onClose={() => setGenerateOpen(false)}
onCreated={handleGenerated}
/>
{revokeTarget ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => setRevokeTarget(null)}
>
<div
className="w-full max-w-sm rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-2 text-lg font-semibold">{t("revoke")}</h2>
<p className="mb-4 text-sm text-muted-foreground">
{t("revokeConfirm")}
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setRevokeTarget(null)}
disabled={revokeMutation.loading}
>
{tCommon("form.cancel")}
</Button>
<Button
type="button"
variant="destructive"
disabled={revokeMutation.loading}
onClick={() => void handleRevoke()}
>
{revokeMutation.loading
? tCommon("form.processing")
: t("revoke")}
</Button>
</div>
</div>
</div>
) : null}
</div>
</div>
);
}
// ── InvitationStatusBadge ───────────────────────────────────────
/**
* 邀请码状态徽章。
*
* - active默认变体primary 色)
* - 其他used/expired/revokedsecondary 变体
*/
function InvitationStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const t = useTranslations("admin.school.classes.invitation");
const variant = status === "active" ? "default" : "secondary";
const label =
status === "active" ||
status === "used" ||
status === "expired" ||
status === "revoked"
? t(`status.${status}`)
: status;
return <Badge variant={variant}>{label}</Badge>;
}
// ── GenerateCodeDialog ──────────────────────────────────────────
/**
* 生成新邀请码对话框。
*
* 字段:有效期(小时)/ 最大使用次数 / 备注
* 全部可选,留空则生成无限制邀请码。
*/
function GenerateCodeDialog({
open,
classId,
onClose,
onCreated,
}: {
open: boolean;
classId: string;
onClose: () => void;
onCreated: () => void;
}): React.ReactElement | null {
const t = useTranslations("admin.school.classes.invitation");
const tCommon = useTranslations("common");
const generateMutation = useGenerateClassInvitationCode();
const [expiresInHours, setExpiresInHours] = useState<string>("");
const [maxUses, setMaxUses] = useState<string>("");
const [note, setNote] = useState<string>("");
const handleSubmit = async (
e: React.FormEvent<HTMLFormElement>,
): Promise<void> => {
e.preventDefault();
if (!classId) return;
try {
await generateMutation.run({
classId,
expiresInHours: expiresInHours ? Number(expiresInHours) : null,
maxUses: maxUses ? Number(maxUses) : null,
note: note.trim() || null,
});
notify.success(t("generateSuccess"));
setExpiresInHours("");
setMaxUses("");
setNote("");
onCreated();
onClose();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-1 text-lg font-semibold">
{t("generateWithCustom")}
</h2>
<p className="mb-4 text-xs text-muted-foreground">
{t("defaultDuration")} · {t("defaultMaxUses")}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<FormField label={t("expiresInHours")}>
<Input
type="number"
min="1"
value={expiresInHours}
onChange={(e) => setExpiresInHours(e.target.value)}
placeholder={t("defaultDuration")}
/>
</FormField>
<FormField label={t("maxUsesLabel")}>
<Input
type="number"
min="1"
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
placeholder={t("defaultMaxUses")}
/>
</FormField>
<FormField label={t("customNote")}>
<Input
type="text"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("customNotePlaceholder")}
maxLength={255}
/>
</FormField>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={generateMutation.loading}
>
{tCommon("form.cancel")}
</Button>
<Button type="submit" disabled={generateMutation.loading}>
{generateMutation.loading
? tCommon("form.processing")
: t("generate")}
</Button>
</div>
</form>
</div>
</div>
);
}