- 学生域 32 页全量迁移(含作答/自动保存/提交/诊断) - 补齐 4 个 MSW mock 缺口,修 diagnostic case 名 - 修 4 处 Tailwind 任意值;新增共享组件与路由
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
"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 {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/shared/components/ui/alert-dialog";
|
||
import { Badge } from "@/shared/components/ui/badge";
|
||
import { Button } from "@/shared/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/shared/components/ui/dialog";
|
||
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 (
|
||
<Dialog
|
||
open={open}
|
||
onOpenChange={(o) => {
|
||
if (!o) onClose();
|
||
}}
|
||
>
|
||
<DialogContent className="flex max-h-[90vh] max-w-3xl flex-col p-6">
|
||
<DialogHeader>
|
||
<DialogTitle>{t("title")}</DialogTitle>
|
||
<DialogDescription className="sr-only">
|
||
{t("title")}
|
||
</DialogDescription>
|
||
{className ? (
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("classLabel")}: {className}
|
||
</p>
|
||
) : null}
|
||
</DialogHeader>
|
||
<div className="flex justify-end">
|
||
<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-xs 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>
|
||
|
||
<DialogFooter className="mt-4">
|
||
<Button variant="outline" onClick={onClose}>
|
||
{tCommon("form.close")}
|
||
</Button>
|
||
</DialogFooter>
|
||
|
||
<GenerateCodeDialog
|
||
open={generateOpen}
|
||
classId={classId}
|
||
onClose={() => setGenerateOpen(false)}
|
||
onCreated={handleGenerated}
|
||
/>
|
||
|
||
{revokeTarget ? (
|
||
<AlertDialog
|
||
open={true}
|
||
onOpenChange={(o) => {
|
||
if (!o && !revokeMutation.loading) setRevokeTarget(null);
|
||
}}
|
||
>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>{t("revoke")}</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
{t("revokeConfirm")}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel
|
||
disabled={revokeMutation.loading}
|
||
onClick={() => setRevokeTarget(null)}
|
||
>
|
||
{tCommon("form.cancel")}
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
disabled={revokeMutation.loading}
|
||
onClick={() => void handleRevoke()}
|
||
>
|
||
{revokeMutation.loading
|
||
? tCommon("form.processing")
|
||
: t("revoke")}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
) : null}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<Dialog
|
||
open={open}
|
||
onOpenChange={(o) => {
|
||
if (!o) onClose();
|
||
}}
|
||
>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>{t("generateWithCustom")}</DialogTitle>
|
||
<DialogDescription>
|
||
{t("defaultDuration")} · {t("defaultMaxUses")}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<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>
|
||
<DialogFooter>
|
||
<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>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|