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:
@@ -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/revoked):secondary 变体
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user