Files
Edu/apps/portal-shell/src/features/admin/school/class-invitation-manager.tsx
SpecialX 04b7a40bdc feat(portal-shell): 学生域全页面迁移与规范合规修复
- 学生域 32 页全量迁移(含作答/自动保存/提交/诊断)

- 补齐 4 个 MSW mock 缺口,修 diagnostic case 名

- 修 4 处 Tailwind 任意值;新增共享组件与路由
2026-08-31 11:25:21 +08:00

425 lines
14 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 §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/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 (
<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>
);
}