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

@@ -13,6 +13,13 @@ import { ListPageSkeleton } from "@/shared/components/page-templates";
* - aiProviders(scope) ❌ schema 未就绪 → MSW 兜底(@contract-pending
* - aiUsageDashboard(range) ❌ schema 未就绪 → MSW 兜底(@contract-pending
*
* 权限模型双权限CICD admin/ai-settings 对齐):
* - AI_CHAT普通用户可访问自己的 private provider
* - AI_CONFIGURE管理员额外权限管理 public provider 与他人 private provider
* - 路由登记route-permissions.ts EXACT `/shell/admin/ai-settings` → ["admin"]
* (管理员隐含 AI_CONFIGURE普通用户访问自己的 provider 走 /shell/ai-settings
* 该路由不在 admin 域,故本页只面向管理员视角)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
export default function AiSettingsPage(): React.ReactElement {

View File

@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
/**
* 班级管理入口重定向ARCHITECTURE.md §9.4 / §10 P5
*
* /shell/admin/classes 默认跳转到学校管理下的班级列表页,避免空白入口
* (班级管理归属于 school 限界上下文)。
*
* 关联ARCHITECTURE.md §5.3 / §9.4 / §10 P5
*/
export default function ClassesAdminIndexPage(): never {
redirect("/shell/admin/school/classes");
}

View File

@@ -10,8 +10,8 @@ import { ListPageSkeleton } from "@/shared/components/page-templates";
* 业务逻辑在 PluginsClientclient component中。
*
* 数据契约:
* - pluginRegistry schema 就绪config-service
* - updatePluginRegistry(pluginId, input) schema 已就绪
* - pluginRegistry schema 就绪config-service 实际暴露 plugins/layoutTemplates/userLayoutOverride/pluginConfig无 pluginRegistry 字段requiredRoles/defaultSlot/defaultSize/defaultProps/propsSchema 字段缺失)→ MSW 兜底(@contract-pending
* - updatePluginRegistry(pluginId, input) schema 无 Mutation 类型 → MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/

View File

@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
/**
* 排课管理入口重定向ARCHITECTURE.md §9.4 / §10 P5
*
* /shell/admin/scheduling 默认跳转到排课变更审批子页,避免空白入口。
*
* 关联ARCHITECTURE.md §5.3 / §9.4 / §10 P5
*/
export default function SchedulingAdminIndexPage(): never {
redirect("/shell/admin/scheduling/changes");
}

View File

@@ -0,0 +1,25 @@
import { Suspense } from "react";
import { GradeInsightsClient } from "@/features/admin/school/grade-insights-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 年级洞察页ARCHITECTURE.md §9.4 管理域 / §10 P5
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 GradeInsightsClientclient component中。
*
* 数据契约schoolWideGradeSummary()@contract-pending → MSW 兜底)
* - overallStats全校平均分 / 及格率 / 优秀率 / 参考人数
* - grades[]:各年级统计 + 班级排名
* - recentAssignments[]:最近作业列表
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5 / §11.3 / §11.4
*/
export default function GradeInsightsPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<GradeInsightsClient />
</Suspense>
);
}

View File

@@ -13,6 +13,11 @@ import { ListPageSkeleton } from "@/shared/components/page-templates";
* - viewports ❌ schema 未就绪 → MSW 兜底(@contract-pending
* - updateViewport(id, input) ❌ schema 无 Mutation → MSW 兜底
*
* DataScope 说明CICD ctx.dataScope 对齐):
* - 视口配置属管理员全局视角DataScope = "all"),不随班级/年级范围收窄
* - 普通教师/学生/家长角色不访问本页route-permissions.ts 仅放行 ["admin"]
* - 与 CICD 中 admin/* 视图一致:管理员 = 全校 DataScope无需 ctx.dataScope 上下文
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
export default function ViewportsPage(): React.ReactElement {

View File

@@ -21,37 +21,40 @@ import {
describe("formatProviderType", () => {
it("maps known types to display labels", () => {
expect(formatProviderType("zhipu")).toBe("智谱 AI");
expect(formatProviderType("openai")).toBe("OpenAI");
expect(formatProviderType("anthropic")).toBe("Anthropic");
expect(formatProviderType("azure")).toBe("Azure OpenAI");
expect(formatProviderType("local")).toBe("本地模型");
expect(formatProviderType("gemini")).toBe("Google Gemini");
expect(formatProviderType("ollama")).toBe("Ollama本地");
expect(formatProviderType("custom")).toBe("自定义");
});
it("returns original value for unknown type", () => {
expect(formatProviderType("custom")).toBe("custom");
expect(formatProviderType("unknown-vendor")).toBe("unknown-vendor");
expect(formatProviderType("")).toBe("");
});
it("PROVIDER_TYPE_LABEL covers 4 standard types", () => {
expect(Object.keys(PROVIDER_TYPE_LABEL)).toHaveLength(4);
it("PROVIDER_TYPE_LABEL covers 5 standard types", () => {
expect(Object.keys(PROVIDER_TYPE_LABEL)).toHaveLength(5);
});
});
describe("isValidProviderType", () => {
it("returns true for supported types", () => {
expect(isValidProviderType("zhipu")).toBe(true);
expect(isValidProviderType("openai")).toBe(true);
expect(isValidProviderType("anthropic")).toBe(true);
expect(isValidProviderType("azure")).toBe(true);
expect(isValidProviderType("local")).toBe(true);
expect(isValidProviderType("gemini")).toBe(true);
expect(isValidProviderType("ollama")).toBe(true);
expect(isValidProviderType("custom")).toBe(true);
});
it("returns false for unsupported types", () => {
expect(isValidProviderType("custom")).toBe(false);
expect(isValidProviderType("anthropic")).toBe(false);
expect(isValidProviderType("azure")).toBe(false);
expect(isValidProviderType("")).toBe(false);
});
it("PROVIDER_TYPES has exactly 4 entries", () => {
expect(PROVIDER_TYPES).toHaveLength(4);
it("PROVIDER_TYPES has exactly 5 entries", () => {
expect(PROVIDER_TYPES).toHaveLength(5);
});
});

View File

@@ -0,0 +1,123 @@
"use client";
/**
* AI Provider 删除确认对话框
*
* 数据契约deleteAiProvider mutation ❌ schema 无 Mutation → MSW 兜底(@contract-pending
*
* 安全删除模式:用户必须输入 Provider 名称才能确认删除(防误操作)。
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §11.3 / §11.4
*/
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import type { AiProvider } from "@/lib/api";
import { useDeleteAiProvider } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
export interface AiProviderDeleteDialogProps {
provider: AiProvider | null;
open: boolean;
onClose: () => void;
onDeleted: () => Promise<void> | void;
}
export function AiProviderDeleteDialog({
provider,
open,
onClose,
onDeleted,
}: AiProviderDeleteDialogProps): React.ReactElement {
const t = useTranslations("adminDomain.aiSettings.deleteDialog");
const tError = useTranslations("adminDomain.aiSettings.error");
const deleteProvider = useDeleteAiProvider();
const [confirmText, setConfirmText] = useState<string>("");
const [submitting, setSubmitting] = useState<boolean>(false);
// 打开时重置输入,关闭时也清理
useEffect(() => {
if (open) {
setConfirmText("");
setSubmitting(false);
}
}, [open]);
const providerName = provider?.name ?? "";
const canConfirm = confirmText === providerName && providerName !== "";
const handleConfirm = async (): Promise<void> => {
if (!provider || !canConfirm) return;
setSubmitting(true);
try {
await deleteProvider.run(provider.id);
notify.success(t("success", { name: providerName }));
onClose();
await onDeleted();
} catch (err: unknown) {
notify.error(tError("unknown"));
console.error("Failed to delete AI provider:", err);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>
{t("warning", { name: providerName })}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t("confirmPrompt", { name: providerName })}
</p>
<Input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t("confirmInputPlaceholder")}
disabled={submitting}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
{t("confirmInputHint")}
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={submitting}
>
{t("cancel")}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleConfirm}
disabled={submitting || !canConfirm}
>
{submitting ? t("deleting") : t("confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,401 @@
"use client";
/**
* AI Provider 创建/编辑对话框ARCHITECTURE.md §7.3 / §9.4 / §10 P5
*
* 基于 CICD src/modules/settings/components/ai-provider-settings-card.tsx 适配到 portal-shell
* - Server Actions → Apollo Client mutations + MSW 兜底
* - 表单字段name / type / scope / model / apiBase / apiKey / isActive
* - apiKey 写入 config.apiKey与列表页 extractApiKey 对称
* - 支持 create / edit 双模式
*
* 数据契约:
* - createAiProvider / updateAiProvider❌ schema 无 → MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { z } from "zod";
import type { AiProvider, AiProviderInput } from "@/lib/api";
import {
useCreateAiProvider,
useTestAiProvider,
useUpdateAiProvider,
} from "@/lib/api";
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 { Label } from "@/shared/components/ui/label";
import { Select } from "@/shared/components/ui/select";
import { Switch } from "@/shared/components/ui/switch";
import { notify } from "@/shared/lib/notify";
import {
PROVIDER_TYPES,
PROVIDER_VISIBILITY_OPTIONS,
formatProviderType,
formatProviderVisibility,
} from "@/features/admin/ai-settings/transformations";
export interface AiProviderFormProps {
provider?: AiProvider | null;
mode: "create" | "edit";
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
/** Provider 名称 zod 校验非空字符串1-100 字符。 */
const providerNameSchema = z.string().min(1).max(100);
/** Provider 模型 zod 校验非空字符串1-100 字符。 */
const providerModelSchema = z.string().min(1).max(100);
/** API Base URL zod 校验:可选,若填写必须为合法 http(s) URL。 */
const providerApiBaseSchema = z
.string()
.trim()
.optional()
.refine(
(value) => {
if (!value) return true;
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
},
{ message: "invalid-url" },
);
export function AiProviderForm({
provider,
mode,
open,
onOpenChange,
onSuccess,
}: AiProviderFormProps): React.ReactElement {
const t = useTranslations("admin.aiSettings.form");
const tCommon = useTranslations("common");
const [name, setName] = useState<string>("");
const [type, setType] = useState<string>("openai");
const [scope, setScope] = useState<string>("global");
const [model, setModel] = useState<string>("");
const [apiBase, setApiBase] = useState<string>("");
const [apiKey, setApiKey] = useState<string>("");
const [isActive, setIsActive] = useState<boolean>(false);
const [visibility, setVisibility] = useState<string>("private");
const [isDefault, setIsDefault] = useState<boolean>(false);
const [testResult, setTestResult] = useState<{
ok: boolean;
latencyMs: number;
message: string;
} | null>(null);
const [nameError, setNameError] = useState<string | null>(null);
const [modelError, setModelError] = useState<string | null>(null);
const [apiBaseError, setApiBaseError] = useState<string | null>(null);
const { run: createProvider, loading: creating } = useCreateAiProvider();
const { run: updateProvider, loading: updating } = useUpdateAiProvider();
const { run: testProvider, loading: testing } = useTestAiProvider();
const handleTestConnection = async (): Promise<void> => {
if (!provider) return;
setTestResult(null);
try {
const result = await testProvider(provider.id);
setTestResult(result);
if (result.ok) {
notify.success(t("testSuccess", { latency: result.latencyMs }));
} else {
notify.error(t("testFailed", { message: result.message }));
}
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const isWorking = creating || updating;
useEffect(() => {
if (open) {
setName(provider?.name ?? "");
setType(provider?.type ?? "openai");
setScope(provider?.scope ?? "global");
setModel(provider?.model ?? "");
setApiBase(provider?.apiBase ?? "");
// 编辑模式下不回填 apiKey安全考虑服务端不返回明文 key
setApiKey("");
setIsActive(provider?.isActive ?? false);
setVisibility(provider?.visibility ?? "private");
setIsDefault(provider?.isDefault ?? false);
setTestResult(null);
setNameError(null);
setModelError(null);
setApiBaseError(null);
}
}, [open, provider]);
const typeOptions = PROVIDER_TYPES.map((value) => ({
value,
label: formatProviderType(value),
}));
const handleSubmit = async (): Promise<void> => {
// zod 校验name 非空、model 非空、apiBase URL 格式(可选)
const trimmedName = name.trim();
const trimmedModel = model.trim();
const trimmedApiBase = apiBase.trim();
const nameValidation = providerNameSchema.safeParse(trimmedName);
if (!nameValidation.success) {
setNameError(t("errorNameRequired"));
return;
}
setNameError(null);
const modelValidation = providerModelSchema.safeParse(trimmedModel);
if (!modelValidation.success) {
setModelError(t("errorModelRequired"));
return;
}
setModelError(null);
const apiBaseValidation = providerApiBaseSchema.safeParse(trimmedApiBase);
if (!apiBaseValidation.success) {
setApiBaseError(t("errorApiBaseInvalid"));
return;
}
setApiBaseError(null);
// 构建 config仅当用户输入 apiKey 时写入(编辑模式留空则保留原 key
const config: Record<string, unknown> = {};
if (apiKey.trim()) {
config.apiKey = apiKey.trim();
}
const input: AiProviderInput = {
name: trimmedName,
type,
scope: scope.trim() || "global",
model: trimmedModel,
apiBase: trimmedApiBase || undefined,
isActive,
isDefault,
visibility,
config: Object.keys(config).length > 0 ? config : undefined,
};
try {
if (mode === "create") {
await createProvider(input);
notify.success(t("createSuccess"));
} else if (provider) {
await updateProvider(provider.id, input);
notify.success(t("updateSuccess"));
}
onSuccess?.();
onOpenChange(false);
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === "create" ? t("titleCreate") : t("titleEdit")}
</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="aip-name">{t("fieldName")}</Label>
<Input
id="aip-name"
value={name}
onChange={(e) => {
setName(e.target.value);
if (nameError) setNameError(null);
}}
placeholder={t("fieldNamePlaceholder")}
aria-invalid={nameError !== null}
/>
{nameError ? (
<p className="text-xs text-destructive">{nameError}</p>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor="aip-type">{t("fieldType")}</Label>
<Select
id="aip-type"
value={type}
onValueChange={setType}
options={typeOptions}
aria-label={t("fieldType")}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="aip-scope">{t("fieldScope")}</Label>
<Input
id="aip-scope"
value={scope}
onChange={(e) => setScope(e.target.value)}
placeholder={t("fieldScopePlaceholder")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="aip-model">{t("fieldModel")}</Label>
<Input
id="aip-model"
value={model}
onChange={(e) => {
setModel(e.target.value);
if (modelError) setModelError(null);
}}
placeholder={t("fieldModelPlaceholder")}
aria-invalid={modelError !== null}
/>
{modelError ? (
<p className="text-xs text-destructive">{modelError}</p>
) : null}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="aip-apibase">{t("fieldApiBase")}</Label>
<Input
id="aip-apibase"
value={apiBase}
onChange={(e) => {
setApiBase(e.target.value);
if (apiBaseError) setApiBaseError(null);
}}
placeholder={t("fieldApiBasePlaceholder")}
aria-invalid={apiBaseError !== null}
/>
{apiBaseError ? (
<p className="text-xs text-destructive">{apiBaseError}</p>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor="aip-apikey">{t("fieldApiKey")}</Label>
<Input
id="aip-apikey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={
mode === "edit"
? t("fieldApiKeyPlaceholderEdit")
: t("fieldApiKeyPlaceholder")
}
/>
<p className="text-xs text-muted-foreground">
{t("fieldApiKeyHint")}
</p>
</div>
<div className="flex items-center gap-3">
<Switch
id="aip-isactive"
checked={isActive}
onCheckedChange={setIsActive}
aria-label={t("fieldIsActive")}
/>
<Label htmlFor="aip-isactive" className="cursor-pointer">
{t("fieldIsActive")}
</Label>
</div>
<div className="flex items-center gap-3">
<Switch
id="aip-isdefault"
checked={isDefault}
onCheckedChange={setIsDefault}
aria-label={t("fieldIsDefault")}
/>
<Label htmlFor="aip-isdefault" className="cursor-pointer">
{t("fieldIsDefault")}
</Label>
</div>
<div className="grid gap-2">
<Label htmlFor="aip-visibility">{t("fieldVisibility")}</Label>
<Select
id="aip-visibility"
value={visibility}
onValueChange={setVisibility}
options={PROVIDER_VISIBILITY_OPTIONS.map((value) => ({
value,
label: formatProviderVisibility(value),
}))}
aria-label={t("fieldVisibility")}
/>
</div>
</div>
{mode === "edit" && provider ? (
<div className="rounded-md border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleTestConnection()}
disabled={testing}
>
{testing ? t("testing") : t("testConnection")}
</Button>
{testResult ? (
<span
className={
testResult.ok
? "text-sm text-emerald-600 dark:text-emerald-400"
: "text-sm text-destructive"
}
>
{testResult.ok
? t("testSuccess", { latency: testResult.latencyMs })
: t("testFailed", { message: testResult.message })}
</span>
) : null}
</div>
</div>
) : null}
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isWorking}
>
{t("cancel")}
</Button>
<Button type="button" onClick={handleSubmit} disabled={isWorking}>
{isWorking ? t("saving") : t("submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -16,13 +16,13 @@
*/
import { Bot, Plus, Trash2, Pencil, PlugZap } from "lucide-react";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import {
useAiProviders,
useAiUsageDashboard,
useDeleteAiProvider,
useTestAiProvider,
type AiProvider,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
@@ -46,9 +46,12 @@ import {
formatCostCents,
formatNumber,
formatProviderType,
formatProviderVisibility,
maskApiKey,
truncateBaseUrl,
} from "@/features/admin/ai-settings/transformations";
import { AiProviderDeleteDialog } from "@/features/admin/ai-settings/ai-provider-delete-dialog";
import { AiProviderForm } from "@/features/admin/ai-settings/ai-provider-form";
/**
* 从 Provider config 中安全提取 apiKey 字符串。未知类型守卫。
@@ -78,10 +81,17 @@ export function AiSettingsClient(): React.ReactElement {
// @contract-pendingMSW 兜底
const { data, loading, error, refetch } = useAiProviders(scope || null);
const usageResult = useAiUsageDashboard(range);
const { run: deleteProvider } = useDeleteAiProvider();
const providers = useMemo<AiProvider[]>(() => data ?? [], [data]);
const [deleteTarget, setDeleteTarget] = useState<AiProvider | null>(null);
const [formOpen, setFormOpen] = useState<boolean>(false);
const [formMode, setFormMode] = useState<"create" | "edit">("create");
const [editTarget, setEditTarget] = useState<AiProvider | null>(null);
const [testingId, setTestingId] = useState<string | null>(null);
const testProvider = useTestAiProvider();
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
@@ -94,21 +104,44 @@ export function AiSettingsClient(): React.ReactElement {
});
};
const handleDelete = async (id: string, name: string): Promise<void> => {
if (!window.confirm(t("delete") + " " + name + " ?")) {
return;
}
try {
await deleteProvider(id);
notify.success(t("delete") + ": " + name);
await refetch();
} catch (err) {
notify.error(`${t("delete")}: ${String(err)}`);
}
const handleDeleteClick = (provider: AiProvider): void => {
setDeleteTarget(provider);
};
const handleTestConnection = (provider: AiProvider): void => {
notify.info(`${t("testConnection")}: ${provider.name}`);
const handleOpenCreate = (): void => {
setEditTarget(null);
setFormMode("create");
setFormOpen(true);
};
const handleOpenEdit = (provider: AiProvider): void => {
setEditTarget(provider);
setFormMode("edit");
setFormOpen(true);
};
const handleFormSuccess = async (): Promise<void> => {
await refetch();
};
const handleDeleted = async (): Promise<void> => {
await refetch();
};
const handleTestConnection = async (provider: AiProvider): Promise<void> => {
setTestingId(provider.id);
try {
const result = await testProvider.run(provider.id);
if (result.ok) {
notify.success(t("testSuccess", { latency: result.latencyMs }));
} else {
notify.error(t("testFailed", { message: result.message }));
}
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
} finally {
setTestingId(null);
}
};
const errorNode = error ? (
@@ -127,7 +160,7 @@ export function AiSettingsClient(): React.ReactElement {
description={t("emptyDescription")}
action={{
label: t("emptyAction"),
href: "/shell/admin/ai-settings",
onClick: handleOpenCreate,
}}
/>
);
@@ -138,11 +171,9 @@ export function AiSettingsClient(): React.ReactElement {
description={t("description")}
icon={<Bot className="size-6" />}
actions={
<Button asChild>
<a href="/shell/admin/ai-settings">
<Plus className="size-4" />
{t("addProviderButton")}
</a>
<Button type="button" onClick={handleOpenCreate}>
<Plus className="size-4" />
{t("addProviderButton")}
</Button>
}
filters={
@@ -188,6 +219,10 @@ export function AiSettingsClient(): React.ReactElement {
<th className="p-3 text-left font-medium">{t("colApiKey")}</th>
<th className="p-3 text-left font-medium">{t("colBaseUrl")}</th>
<th className="p-3 text-left font-medium">{t("colIsActive")}</th>
<th className="p-3 text-left font-medium">
{t("colVisibility")}
</th>
<th className="p-3 text-left font-medium">{t("colIsDefault")}</th>
<th className="p-3 text-left font-medium">{t("colActions")}</th>
</tr>
</thead>
@@ -212,24 +247,36 @@ export function AiSettingsClient(): React.ReactElement {
<td className="p-3">
<ActiveBadge isActive={p.isActive} />
</td>
<td className="p-3 text-muted-foreground">
{formatProviderVisibility(p.visibility)}
</td>
<td className="p-3">
<DefaultBadge isDefault={p.isDefault} />
</td>
<td className="p-3">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestConnection(p)}
onClick={() => void handleTestConnection(p)}
disabled={testingId === p.id}
>
<PlugZap className="size-4" />
{t("testConnection")}
{testingId === p.id ? t("testing") : t("testConnection")}
</Button>
<Button variant="ghost" size="sm" aria-label={t("edit")}>
<Button
variant="ghost"
size="sm"
aria-label={t("edit")}
onClick={() => handleOpenEdit(p)}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
aria-label={t("delete")}
onClick={() => void handleDelete(p.id, p.name)}
onClick={() => handleDeleteClick(p)}
>
<Trash2 className="size-4" />
</Button>
@@ -242,6 +289,21 @@ export function AiSettingsClient(): React.ReactElement {
</div>
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
<AiProviderDeleteDialog
provider={deleteTarget}
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
/>
<AiProviderForm
provider={editTarget}
mode={formMode}
open={formOpen}
onOpenChange={setFormOpen}
onSuccess={handleFormSuccess}
/>
</ListPageShell>
);
}
@@ -269,6 +331,39 @@ function UsageDashboardSection({
}>;
}): React.ReactElement {
const t = useTranslations("admin.aiSettings.list");
const tCommon = useTranslations("common");
const USAGE_PAGE_SIZE = 10;
const [usagePage, setUsagePage] = useState<number>(1);
const usageTotalPages = Math.max(
1,
Math.ceil(byProvider.length / USAGE_PAGE_SIZE),
);
const currentUsagePage = Math.min(Math.max(1, usagePage), usageTotalPages);
const pagedUsageRows = byProvider.slice(
(currentUsagePage - 1) * USAGE_PAGE_SIZE,
currentUsagePage * USAGE_PAGE_SIZE,
);
// byProvider 列表变化时(如切换 range重置页码
useEffect(() => {
setUsagePage(1);
}, [byProvider.length]);
const usagePages: Array<number | "ellipsis"> = (() => {
if (usageTotalPages <= 7) {
return Array.from({ length: usageTotalPages }, (_, i) => i + 1);
}
const result: Array<number | "ellipsis"> = [1];
const start = Math.max(2, currentUsagePage - 1);
const end = Math.min(usageTotalPages - 1, currentUsagePage + 1);
if (start > 2) result.push("ellipsis");
for (let i = start; i <= end; i++) result.push(i);
if (end < usageTotalPages - 1) result.push("ellipsis");
result.push(usageTotalPages);
return result;
})();
return (
<div className="space-y-4">
@@ -299,41 +394,87 @@ function UsageDashboardSection({
{byProvider.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("emptyUsage")}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("colProviderName")}
</th>
<th className="p-2 text-left font-medium">
{t("colRequests")}
</th>
<th className="p-2 text-left font-medium">
{t("colTokens")}
</th>
<th className="p-2 text-left font-medium">
{t("colCostCents")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{byProvider.map((row) => (
<tr key={row.providerId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{row.providerName}</td>
<td className="p-2 font-mono text-xs">
{formatNumber(row.requests)}
</td>
<td className="p-2 font-mono text-xs">
{formatNumber(row.tokens)}
</td>
<td className="p-2 font-mono text-xs">
{formatCostCents(row.costCents)}
</td>
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("colProviderName")}
</th>
<th className="p-2 text-left font-medium">
{t("colRequests")}
</th>
<th className="p-2 text-left font-medium">
{t("colTokens")}
</th>
<th className="p-2 text-left font-medium">
{t("colCostCents")}
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y">
{pagedUsageRows.map((row) => (
<tr key={row.providerId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{row.providerName}</td>
<td className="p-2 font-mono text-xs">
{formatNumber(row.requests)}
</td>
<td className="p-2 font-mono text-xs">
{formatNumber(row.tokens)}
</td>
<td className="p-2 font-mono text-xs">
{formatCostCents(row.costCents)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{byProvider.length > USAGE_PAGE_SIZE ? (
<div className="flex items-center justify-end gap-1 text-sm text-muted-foreground">
<Button
variant="outline"
size="sm"
disabled={currentUsagePage <= 1}
onClick={() => setUsagePage(currentUsagePage - 1)}
>
{tCommon("prev")}
</Button>
{usagePages.map((p, idx) =>
p === "ellipsis" ? (
<span
key={`usage-ellipsis-${idx}`}
className="px-2 text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === currentUsagePage ? "default" : "outline"}
size="sm"
disabled={p === currentUsagePage}
onClick={() => setUsagePage(p)}
aria-current={
p === currentUsagePage ? "page" : undefined
}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="sm"
disabled={currentUsagePage >= usageTotalPages}
onClick={() => setUsagePage(currentUsagePage + 1)}
>
{tCommon("next")}
</Button>
</div>
) : null}
</div>
)}
</CardContent>
@@ -357,3 +498,25 @@ function ActiveBadge({ isActive }: { isActive: boolean }): React.ReactElement {
</span>
);
}
/**
* 默认 Provider 徽章(按 isDefault 渲染默认/非默认徽章)。
*/
function DefaultBadge({
isDefault,
}: {
isDefault: boolean;
}): React.ReactElement {
const t = useTranslations("admin.aiSettings.list");
const label = isDefault ? t("defaultProvider") : t("nonDefaultProvider");
const cls = isDefault
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground";
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -5,22 +5,38 @@
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
/** AI Provider 类型标签映射 */
/** AI Provider 类型标签映射(对齐 CICDzhipu/openai/gemini/ollama/custom */
export const PROVIDER_TYPE_LABEL: Record<string, string> = {
zhipu: "智谱 AI",
openai: "OpenAI",
anthropic: "Anthropic",
azure: "Azure OpenAI",
local: "本地模型",
gemini: "Google Gemini",
ollama: "Ollama本地",
custom: "自定义",
};
/** AI Provider 类型支持的取值列表 */
export const PROVIDER_TYPES: readonly string[] = [
"zhipu",
"openai",
"anthropic",
"azure",
"local",
"gemini",
"ollama",
"custom",
] as const;
/** AI Provider 可见性枚举值列表(@contract-pendingMSW 兜底) */
export const PROVIDER_VISIBILITY_OPTIONS: readonly string[] = [
"private",
"shared",
"public",
] as const;
/** AI Provider 可见性标签映射 */
export const PROVIDER_VISIBILITY_LABEL: Record<string, string> = {
private: "仅自己可见",
shared: "组织共享",
public: "全员可见",
};
/**
* 将 Provider 类型代码映射为展示标签。未知值回退为原始值。
*/
@@ -35,6 +51,13 @@ export function isValidProviderType(type: string): boolean {
return PROVIDER_TYPES.includes(type);
}
/**
* 将 Provider 可见性代码映射为展示标签。未知值回退为原始值。
*/
export function formatProviderVisibility(visibility: string): string {
return PROVIDER_VISIBILITY_LABEL[visibility] ?? visibility;
}
/**
* 根据 isActive 返回 Tailwind 徽章语义类名。
*/

View File

@@ -0,0 +1,233 @@
"use client";
/**
* 公告内联创建对话框CICD admin-announcements-view 列表页内联 Dialog 对齐)
*
* 数据契约:
* - mutation createAnnouncement(input) ❌ schema 无 → MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { Megaphone } from "lucide-react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCreateAnnouncement, type AnnouncementInput } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { notify } from "@/shared/lib/notify";
import { GradeMultiSelect } from "@/features/admin/announcements/grade-multi-select";
/** 可选状态枚举(与列表筛选对齐) */
const STATUS_OPTIONS = ["draft", "published", "archived"] as const;
/** 可选受众枚举 */
const AUDIENCE_OPTIONS = ["all", "teachers", "students", "parents"] as const;
interface AnnouncementCreateDialogProps {
/** 受控打开状态(外部受控时使用) */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** 触发器(默认渲染为按钮) */
trigger?: React.ReactNode;
}
/**
* 公告创建对话框。支持非受控(默认按钮触发)与受控两种模式。
*
* 创建成功后:
* - 关闭对话框
* - notify.success 提示
* - router.refresh() 刷新列表
*/
export function AnnouncementCreateDialog(
props: AnnouncementCreateDialogProps,
): React.ReactElement {
const t = useTranslations("admin.announcements.create");
const tCommon = useTranslations("common");
const router = useRouter();
const { run: createAnnouncement, loading: submitting } =
useCreateAnnouncement();
const [internalOpen, setInternalOpen] = useState(false);
const open = props.open ?? internalOpen;
const setOpen = props.onOpenChange ?? setInternalOpen;
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [status, setStatus] = useState<string>("draft");
const [audience, setAudience] = useState<string>("all");
const [grades, setGrades] = useState<string[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const resetForm = (): void => {
setTitle("");
setContent("");
setStatus("draft");
setAudience("all");
setGrades([]);
setFormError(null);
};
const handleOpenChange = (next: boolean): void => {
setOpen(next);
if (!next) {
// 关闭时重置表单
resetForm();
}
};
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
setFormError(null);
if (!title.trim()) {
setFormError(t("errorTitleRequired"));
return;
}
if (!content.trim()) {
setFormError(t("errorContentRequired"));
return;
}
const input: AnnouncementInput = {
title: title.trim(),
content: content.trim(),
status,
audience,
grades,
};
try {
await createAnnouncement(input);
notify.success(t("submitSuccess"));
handleOpenChange(false);
router.refresh();
} catch (err) {
setFormError(tCommon("error.operationFailed", { message: String(err) }));
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{props.trigger ?? (
<Button>
<Megaphone className="size-4" />
{t("triggerButton")}
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ann-title">{t("fieldTitle")}</Label>
<Input
id="ann-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("fieldTitlePlaceholder")}
maxLength={200}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="ann-content">{t("fieldContent")}</Label>
<Textarea
id="ann-content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("fieldContentPlaceholder")}
rows={6}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ann-status">{t("fieldStatus")}</Label>
<select
id="ann-status"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{t(`statusOption_${s}`)}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="ann-audience">{t("fieldAudience")}</Label>
<select
id="ann-audience"
value={audience}
onChange={(e) => setAudience(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{AUDIENCE_OPTIONS.map((a) => (
<option key={a} value={a}>
{t(`audienceOption_${a}`)}
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label>{t("fieldGrades")}</Label>
<GradeMultiSelect
selectedIds={grades}
onChange={setGrades}
disabled={submitting}
/>
<p className="text-xs text-muted-foreground">
{t("fieldGradesDescription")}
</p>
</div>
{formError ? (
<p className="text-sm text-destructive" role="alert">
{formError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={submitting}
>
{tCommon("button.cancel")}
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? t("submitting") : t("submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -16,6 +16,7 @@
*/
import { Megaphone } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -24,6 +25,7 @@ import {
useArchiveAnnouncement,
useDeleteAnnouncement,
usePinAnnouncement,
usePublishAnnouncement,
type AnnouncementDetail,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
@@ -33,6 +35,16 @@ import {
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { notify } from "@/shared/lib/notify";
import {
announcementStatusToBadgeClass,
@@ -57,9 +69,13 @@ export function AnnouncementDetailClient(): React.ReactElement {
const { data, loading, error } = useAdminAnnouncement(announcementId);
// @contract-pendingMSW 兜底
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// @contract-pending: MSW
const { run: archiveAnnouncement } = useArchiveAnnouncement();
const { run: pinAnnouncement } = usePinAnnouncement();
const { run: deleteAnnouncement } = useDeleteAnnouncement();
const { run: publishAnnouncement } = usePublishAnnouncement();
const handleArchive = async (): Promise<void> => {
try {
@@ -81,11 +97,20 @@ export function AnnouncementDetailClient(): React.ReactElement {
}
};
const handlePublish = async (): Promise<void> => {
try {
await publishAnnouncement(announcementId);
notify.success(t("publish"));
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleDelete = async (): Promise<void> => {
if (!window.confirm(t("deleteConfirm"))) return;
try {
await deleteAnnouncement(announcementId);
notify.success(t("delete"));
setDeleteDialogOpen(false);
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
@@ -123,9 +148,36 @@ export function AnnouncementDetailClient(): React.ReactElement {
{t("archive")}
</Button>
) : null}
<Button variant="destructive" onClick={() => void handleDelete()}>
{data.status === "draft" ? (
<Button onClick={() => void handlePublish()}>
{t("publish")}
</Button>
) : null}
<Button
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
>
{t("delete")}
</Button>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteConfirmDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => void handleDelete()}>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
) : null
}
@@ -167,6 +219,10 @@ function AnnouncementDetailBody({
value={formatAudience(announcement.audience)}
/>
<DetailField label={t("fieldAuthor")} value={announcement.authorName} />
<DetailField
label={t("fieldReadCount")}
value={String(announcement.readCount ?? 0)}
/>
<DetailField
label={t("fieldPinnedAt")}
value={formatAnnouncementDate(announcement.pinnedAt)}

View File

@@ -26,8 +26,16 @@ import {
type AnnouncementInput,
} from "@/lib/api";
import { FormPageShell } from "@/shared/components/page-templates";
import { Input } from "@/shared/components/ui/input";
import { Textarea } from "@/shared/components/ui/textarea";
import { Select } from "@/shared/components/ui/select";
import { notify } from "@/shared/lib/notify";
import { isAnnouncementPinned } from "@/features/admin/announcements/transformations";
import {
formatAnnouncementStatus,
formatAudience,
isAnnouncementPinned,
} from "@/features/admin/announcements/transformations";
import { GradeMultiSelect } from "@/features/admin/announcements/grade-multi-select";
/** 可选状态枚举(与列表筛选对齐) */
const STATUS_OPTIONS = ["draft", "published", "archived"] as const;
@@ -58,6 +66,7 @@ export function AnnouncementEditClient(): React.ReactElement {
const [audience, setAudience] = useState<string>("all");
const [pinned, setPinned] = useState(false);
const [originalPinned, setOriginalPinned] = useState(false);
const [grades, setGrades] = useState<string[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
@@ -71,6 +80,7 @@ export function AnnouncementEditClient(): React.ReactElement {
const isPinned = isAnnouncementPinned(data.pinnedAt);
setPinned(isPinned);
setOriginalPinned(isPinned);
setGrades(data.grades ?? []);
setInitialized(true);
}
}, [data, initialized]);
@@ -92,6 +102,7 @@ export function AnnouncementEditClient(): React.ReactElement {
content: content.trim(),
status,
audience,
grades,
};
try {
@@ -161,22 +172,20 @@ export function AnnouncementEditClient(): React.ReactElement {
>
{/* 标题 */}
<FormField label={t("fieldTitle")} required>
<input
<Input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
{/* 内容 */}
<FormField label={t("fieldContent")} required>
<textarea
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={6}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
required
/>
</FormField>
@@ -184,31 +193,27 @@ export function AnnouncementEditClient(): React.ReactElement {
{/* 状态 + 受众 */}
<div className="grid grid-cols-2 gap-4">
<FormField label={t("fieldStatus")}>
<select
<Select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
onValueChange={setStatus}
options={STATUS_OPTIONS.map((s) => ({
value: s,
label: formatAnnouncementStatus(s),
}))}
aria-label={t("fieldStatus")}
/>
</FormField>
<FormField label={t("fieldAudience")}>
<select
<Select
value={audience}
onChange={(e) => setAudience(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{AUDIENCE_OPTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
onValueChange={setAudience}
options={AUDIENCE_OPTIONS.map((a) => ({
value: a,
label: formatAudience(a),
}))}
aria-label={t("fieldAudience")}
/>
</FormField>
</div>
@@ -224,6 +229,15 @@ export function AnnouncementEditClient(): React.ReactElement {
<span className="text-muted-foreground">{t("fieldPinned")}</span>
</label>
</FormField>
{/* 关联年级(多选下拉) */}
<FormField label={t("fieldGrades")}>
<GradeMultiSelect
selectedIds={grades}
onChange={setGrades}
disabled={submitting}
/>
</FormField>
</FormPageShell>
);
}

View File

@@ -13,10 +13,10 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { Megaphone } from "lucide-react";
import { Megaphone, Plus } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import {
@@ -24,15 +24,27 @@ import {
useArchiveAnnouncement,
useDeleteAnnouncement,
usePinAnnouncement,
usePublishAnnouncement,
type AnnouncementListItem,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import { AnnouncementCreateDialog } from "@/features/admin/announcements/announcement-create-dialog";
import {
announcementStatusToBadgeClass,
formatAnnouncementDate,
@@ -55,6 +67,8 @@ export function AnnouncementsListClient(): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string>("");
const statusParam = searchParams.get("status") ?? "";
const status = STATUS_OPTIONS.includes(statusParam as StatusOption)
@@ -70,6 +84,7 @@ export function AnnouncementsListClient(): React.ReactElement {
const { run: archiveAnnouncement } = useArchiveAnnouncement();
const { run: pinAnnouncement } = usePinAnnouncement();
const { run: deleteAnnouncement } = useDeleteAnnouncement();
const { run: publishAnnouncement } = usePublishAnnouncement();
const items = data?.items ?? [];
@@ -110,16 +125,30 @@ export function AnnouncementsListClient(): React.ReactElement {
}
};
const handleDelete = async (id: string): Promise<void> => {
if (!window.confirm(t("deleteConfirm"))) return;
const handlePublish = async (id: string): Promise<void> => {
try {
await deleteAnnouncement(id);
notify.success(t("delete"));
await publishAnnouncement(id);
notify.success(t("publish"));
} catch (err) {
notify.error(`${tCommon("error.loadFailed", { message: String(err) })}`);
}
};
const handleDelete = async (): Promise<void> => {
try {
await deleteAnnouncement(deleteTargetId);
notify.success(t("delete"));
setDeleteDialogOpen(false);
} catch (err) {
notify.error(`${tCommon("error.loadFailed", { message: String(err) })}`);
}
};
const openDeleteDialog = (id: string): void => {
setDeleteTargetId(id);
setDeleteDialogOpen(true);
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
@@ -147,11 +176,14 @@ export function AnnouncementsListClient(): React.ReactElement {
description={t("description")}
icon={<Megaphone className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/admin/announcements/new">
{t("newAnnouncement")}
</Link>
</Button>
<AnnouncementCreateDialog
trigger={
<Button>
<Plus className="size-4" />
{t("newAnnouncement")}
</Button>
}
/>
}
filters={
<label className="flex items-center gap-2 text-sm">
@@ -175,9 +207,12 @@ export function AnnouncementsListClient(): React.ReactElement {
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: data?.total ?? 0 })}</span>
</div>
<PaginationBar
total={data?.total ?? 0}
page={page}
pageSize={10}
onNavigate={(p) => updateQuery("page", String(p))}
/>
}
>
<AnnouncementsTable
@@ -185,8 +220,25 @@ export function AnnouncementsListClient(): React.ReactElement {
page={page}
onArchive={handleArchive}
onPinToggle={handlePinToggle}
onDelete={handleDelete}
onPublish={handlePublish}
onDelete={openDeleteDialog}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteConfirmDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => void handleDelete()}>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ListPageShell>
);
}
@@ -199,13 +251,15 @@ function AnnouncementsTable({
page,
onArchive,
onPinToggle,
onPublish,
onDelete,
}: {
items: AnnouncementListItem[];
page: number;
onArchive: (id: string) => Promise<void>;
onPinToggle: (id: string, pinned: boolean) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onPublish: (id: string) => Promise<void>;
onDelete: (id: string) => void;
}): React.ReactElement {
const t = useTranslations("admin.announcements.list");
return (
@@ -216,6 +270,7 @@ function AnnouncementsTable({
<th className="p-3 text-left font-medium">{t("colTitle")}</th>
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
<th className="p-3 text-left font-medium">{t("colAudience")}</th>
<th className="p-3 text-left font-medium">{t("colReadCount")}</th>
<th className="p-3 text-left font-medium">{t("colPinnedAt")}</th>
<th className="p-3 text-left font-medium">{t("colPublishedAt")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
@@ -240,6 +295,9 @@ function AnnouncementsTable({
<td className="p-3 text-muted-foreground">
{formatAudience(item.audience)}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{item.readCount}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatAnnouncementDate(item.pinnedAt)}
</td>
@@ -262,6 +320,15 @@ function AnnouncementsTable({
{t("edit")}
</Link>
</Button>
{item.status === "draft" ? (
<Button
variant="ghost"
size="sm"
onClick={() => void onPublish(item.id)}
>
{t("publish")}
</Button>
) : null}
<Button
variant="ghost"
size="sm"
@@ -279,7 +346,7 @@ function AnnouncementsTable({
<Button
variant="ghost"
size="sm"
onClick={() => void onDelete(item.id)}
onClick={() => onDelete(item.id)}
>
{t("delete")}
</Button>
@@ -308,3 +375,92 @@ function StatusBadge({ status }: { status: string }): React.ReactElement {
</span>
);
}
/**
* 分页条(页码按钮 + 上一页/下一页 + 总数)。
* 总页数 > 7 时使用窗口策略(首末页 + 当前页 ±1 + 省略号)。
*/
function PaginationBar({
total,
page,
pageSize,
onNavigate,
}: {
total: number;
page: number;
pageSize: number;
onNavigate: (page: number) => void;
}): React.ReactElement {
const t = useTranslations("admin.announcements.list");
const tCommon = useTranslations("common");
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(Math.max(1, page), totalPages);
const pages: Array<number | "ellipsis"> = (() => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const result: Array<number | "ellipsis"> = [1];
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
if (start > 2) result.push("ellipsis");
for (let i = start; i <= end; i++) result.push(i);
if (end < totalPages - 1) result.push("ellipsis");
result.push(totalPages);
return result;
})();
if (total === 0) {
return (
<div className="flex items-center justify-end text-sm text-muted-foreground">
<span>{t("total", { count: 0 })}</span>
</div>
);
}
return (
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: total })}</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => onNavigate(currentPage - 1)}
>
{tCommon("prev")}
</Button>
{pages.map((p, idx) =>
p === "ellipsis" ? (
<span
key={`ellipsis-${idx}`}
className="px-2 text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === currentPage ? "default" : "outline"}
size="sm"
disabled={p === currentPage}
onClick={() => onNavigate(p)}
aria-current={p === currentPage ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => onNavigate(currentPage + 1)}
>
{tCommon("next")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
/**
* 年级多选下拉组件CICD announcements AnnouncementForm 多选下拉对齐)
*
* 数据契约useGrades() 来自 admin-p5.ts@contract-pendingMSW 兜底)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5 / §11.3
*/
import { Check, ChevronDown, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useGrades } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
interface GradeMultiSelectProps {
selectedIds: string[];
onChange: (ids: string[]) => void;
disabled?: boolean;
}
export function GradeMultiSelect({
selectedIds,
onChange,
disabled = false,
}: GradeMultiSelectProps): React.ReactElement {
const t = useTranslations("admin.announcements.edit.multiSelect");
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const { data: grades, loading, error } = useGrades();
useEffect(() => {
if (error) {
notify.error(String(error));
}
}, [error]);
// 点击外部关闭下拉
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent): void => {
if (
containerRef.current &&
e.target instanceof Node &&
!containerRef.current.contains(e.target)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [open]);
const handleToggle = (gradeId: string): void => {
if (selectedIds.includes(gradeId)) {
onChange(selectedIds.filter((id) => id !== gradeId));
} else {
onChange([...selectedIds, gradeId]);
}
};
const handleRemove = (gradeId: string): void => {
onChange(selectedIds.filter((id) => id !== gradeId));
};
const handleToggleAll = (): void => {
if (!grades) return;
if (selectedIds.length === grades.length) {
onChange([]);
} else {
onChange(grades.map((g) => g.id));
}
};
const selectedGrades = (grades ?? []).filter((g) =>
selectedIds.includes(g.id),
);
return (
<div ref={containerRef} className="relative w-full">
{/* 已选项 Badge 列表 */}
{selectedGrades.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{selectedGrades.map((g) => (
<span
key={g.id}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
>
{g.name}
<button
type="button"
onClick={() => handleRemove(g.id)}
disabled={disabled}
aria-label={t("remove", { name: g.name })}
className="ml-0.5 rounded-full hover:bg-primary/20 disabled:opacity-50"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
{/* 触发按钮 */}
<button
type="button"
onClick={() => setOpen(!open)}
disabled={disabled || loading}
aria-expanded={open}
className="flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 text-sm text-left disabled:opacity-50"
>
<span
className={selectedIds.length === 0 ? "text-muted-foreground" : ""}
>
{selectedIds.length === 0
? t("placeholder")
: t("selected", { count: selectedIds.length })}
</span>
<ChevronDown
className={`size-4 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{/* 下拉面板 */}
{open && (
<div className="absolute z-50 mt-1 w-full rounded-md border border-input bg-background shadow-lg">
{loading && (
<div className="p-2 text-center text-xs text-muted-foreground">
{t("loading")}
</div>
)}
{!loading && (grades?.length ?? 0) === 0 && (
<div className="p-2 text-center text-xs text-muted-foreground">
{t("empty")}
</div>
)}
{!loading && (grades?.length ?? 0) > 0 && (
<>
<div className="border-b border-input p-2">
<button
type="button"
onClick={handleToggleAll}
className="text-xs text-primary hover:underline"
>
{selectedIds.length === grades?.length
? t("toggle", { action: "clear" })
: t("toggle", { action: "all" })}
</button>
</div>
<div className="max-h-60 overflow-y-auto">
{(grades ?? []).map((g) => {
const checked = selectedIds.includes(g.id);
return (
<label
key={g.id}
className="flex cursor-pointer items-center gap-2 p-2 hover:bg-muted/30"
>
<input
type="checkbox"
checked={checked}
onChange={() => handleToggle(g.id)}
disabled={disabled}
className="size-4 rounded border-input"
/>
<span className="text-sm">{g.name}</span>
{checked && (
<Check className="ml-auto size-3 text-primary" />
)}
</label>
);
})}
</div>
</>
)}
</div>
)}
</div>
);
}

View File

@@ -64,6 +64,7 @@ const baseRecords: AdminAttendanceRecord[] = [
studentName: "张三",
classId: "cls-1",
className: "高三(1)班",
gradeId: "g-1",
date: "2026-07-22",
status: "present",
recordedBy: "李老师",
@@ -75,6 +76,7 @@ const baseRecords: AdminAttendanceRecord[] = [
studentName: "李四",
classId: "cls-2",
className: "高三(2)班",
gradeId: "g-1",
date: "2026-07-22",
status: "absent",
recordedBy: "李老师",
@@ -86,6 +88,7 @@ const baseRecords: AdminAttendanceRecord[] = [
studentName: "王五",
classId: "cls-1",
className: "高三(1)班",
gradeId: "g-1",
date: "2026-07-23",
status: "late",
recordedBy: "王老师",

View File

@@ -7,8 +7,10 @@
* - adminAttendanceStats():❌ schema 无 → MSW 兜底(@contract-pending
* - attendanceGradeCorrelation():❌ schema 无 → MSW 兜底
* - adminClasses():❌ schema 无 → MSW 兜底(用于班级筛选下拉)
* - grades():✅ 真实 schema用于年级筛选下拉
* - classComparison():❌ schema 无 → MSW 兜底(由 ClassComparisonCard 内部调用)
*
* URL 状态:?classId=xxx&status=xxx&date=xxx
* URL 状态:?classId=xxx&status=xxx&date=xxx&gradeId=xxx&page=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
@@ -23,8 +25,8 @@ import {
useAdminAttendanceStats,
useAdminClasses,
useAttendanceGradeCorrelation,
useGrades,
} from "@/lib/api";
import { Card, CardContent } from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { StatCard } from "@/shared/components/ui/stat-card";
import {
@@ -32,21 +34,20 @@ import {
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
attendanceStatusToBadgeClass,
attendanceStatusToKey,
computeAbnormalRate,
computeAvgCorrelation,
formatAbnormalRate,
formatAvgScore,
formatCorrelation,
formatRate,
formatRecordDate,
hasAttendanceData,
presentRateToColorClass,
sortClassesByPresentRate,
truncateNote,
type AttendanceStatus,
} from "@/features/admin/attendance/transformations";
import { ClassComparisonCard } from "@/features/admin/attendance/class-comparison-card";
import { AttendanceGradeCorrelationCard } from "@/features/admin/attendance/attendance-grade-correlation-card";
import { AttendanceRecordsList } from "@/features/admin/attendance/attendance-records-list";
/** 考勤状态选项(用于筛选下拉) */
const STATUS_OPTIONS: readonly AttendanceStatus[] = [
@@ -70,11 +71,13 @@ export function AdminAttendanceClient(): React.ReactElement {
const classId = searchParams.get("classId") ?? "";
const status = searchParams.get("status") ?? "";
const date = searchParams.get("date") ?? "";
const gradeId = searchParams.get("gradeId") ?? "";
// @contract-pendingMSW 兜底
const { data: stats, loading, error } = useAdminAttendanceStats();
const { data: correlations } = useAttendanceGradeCorrelation();
const { data: classes } = useAdminClasses();
const { data: grades } = useGrades();
const avgCorrelation = useMemo(
() => computeAvgCorrelation(correlations ?? []),
@@ -88,6 +91,10 @@ export function AdminAttendanceClient(): React.ReactElement {
} else {
params.delete(key);
}
// 切换筛选时重置页码
if (key !== "page") {
params.delete("page");
}
startTransition(() => {
router.push(`/shell/admin/attendance?${params.toString()}`);
});
@@ -122,10 +129,13 @@ export function AdminAttendanceClient(): React.ReactElement {
classId={classId}
status={status}
date={date}
gradeId={gradeId}
classes={classes ?? []}
grades={grades ?? []}
onClassChange={(v) => updateFilter("classId", v)}
onStatusChange={(v) => updateFilter("status", v)}
onDateChange={(v) => updateFilter("date", v)}
onGradeChange={(v) => updateFilter("gradeId", v)}
/>
}
loading={loading}
@@ -135,40 +145,74 @@ export function AdminAttendanceClient(): React.ReactElement {
errorNode={errorNode}
>
{stats ? (
<AttendanceContent
stats={stats}
correlations={correlations ?? []}
avgCorrelation={avgCorrelation}
/>
<AttendanceContent stats={stats} avgCorrelation={avgCorrelation} />
) : null}
{/* 班级对比卡 + 考勤-成绩关联分析卡并排布局lg 以上双列) */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ClassComparisonCard />
<AttendanceGradeCorrelationCard />
</div>
{/* 考勤记录列表:按 classId/status/date/gradeId 筛选 + URL 分页 */}
<AttendanceRecordsList
classId={classId}
status={status}
date={date}
gradeId={gradeId}
/>
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
</ListPageShell>
);
}
/**
* 考勤筛选栏(班级 + 状态 + 日期)。
* 考勤筛选栏(年级 + 班级 + 状态 + 日期)。
*/
function AttendanceFilters({
classId,
status,
date,
gradeId,
classes,
grades,
onClassChange,
onStatusChange,
onDateChange,
onGradeChange,
}: {
classId: string;
status: string;
date: string;
gradeId: string;
classes: NonNullable<ReturnType<typeof useAdminClasses>["data"]>;
grades: NonNullable<ReturnType<typeof useGrades>["data"]>;
onClassChange: (v: string) => void;
onStatusChange: (v: string) => void;
onDateChange: (v: string) => void;
onGradeChange: (v: string) => void;
}): React.ReactElement {
const t = useTranslations("admin.attendance.list");
return (
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">
{t("gradeFilter")}
</label>
<select
value={gradeId}
onChange={(e) => onGradeChange(e.target.value)}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
<option value="">{t("allGrades")}</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">
{t("classFilter")}
@@ -227,17 +271,15 @@ function AttendanceFilters({
}
/**
* 考勤主体内容(统计卡片 + 班级对比 + 考勤-成绩关联分析)。
* 考勤主体内容(统计卡片)。
* 班级对比与考勤-成绩关联分析由独立卡片组件渲染ClassComparisonCard /
* AttendanceGradeCorrelationCard各自管理数据获取与三态。
*/
function AttendanceContent({
stats,
correlations,
avgCorrelation,
}: {
stats: NonNullable<ReturnType<typeof useAdminAttendanceStats>["data"]>;
correlations: NonNullable<
ReturnType<typeof useAttendanceGradeCorrelation>["data"]
>;
avgCorrelation: number;
}): React.ReactElement {
const t = useTranslations("admin.attendance.list");
@@ -246,7 +288,6 @@ function AttendanceContent({
stats.lateRate,
stats.earlyLeaveRate,
);
const sortedClasses = sortClassesByPresentRate(stats);
return (
<div className="flex flex-col gap-6">
@@ -291,117 +332,6 @@ function AttendanceContent({
valueClassName="text-sky-600 dark:text-sky-400"
/>
</div>
{/* 班级对比 */}
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("classComparisonTitle")}
</h2>
{sortedClasses.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyTitle")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("classComparisonClass")}
</th>
<th className="p-2 text-left font-medium">
{t("statsAbsentRate")}
</th>
<th className="p-2 text-left font-medium">
{t("classComparisonRate")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{sortedClasses.map((cls) => (
<tr key={cls.classId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{cls.className}</td>
<td className="p-2 font-mono text-xs text-destructive">
{formatRate(cls.absentRate)}
</td>
<td
className={`p-2 font-mono text-xs ${presentRateToColorClass(cls.presentRate)}`}
>
{formatRate(cls.presentRate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* 考勤-成绩关联分析 */}
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("correlationTitle")}
</h2>
{correlations.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyTitle")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("classComparisonClass")}
</th>
<th className="p-2 text-left font-medium">
{t("correlationAttendance")}
</th>
<th className="p-2 text-left font-medium">
{t("correlationGrade")}
</th>
<th className="p-2 text-left font-medium">
{t("statsAvgCorrelation")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{correlations.map((c) => {
const statusKey = attendanceStatusToKey(
c.presentRate >= 0.9 ? "present" : "absent",
);
return (
<tr key={c.classId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{c.className}</td>
<td
className={`p-2 font-mono text-xs ${presentRateToColorClass(c.presentRate)}`}
>
{formatRate(c.presentRate)}
</td>
<td className="p-2 font-mono text-xs">
{formatAvgScore(c.avgScore)}
</td>
<td className="p-2 font-mono text-xs">
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${attendanceStatusToBadgeClass(
statusKey,
)}`}
>
{formatCorrelation(c.correlation)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,466 @@
"use client";
/**
* 考勤-成绩关联分析卡ARCHITECTURE.md §9.4 / §11.3 DoD 三态)
*
* 数据契约:
* - attendanceGradeCorrelation():❌ schema 无 → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loading骨架屏
* - error局部降级EmptyState + 错误文案)
* - data汇总统计 + 散点图 + 班级明细表格
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §11.3 / §11.4
*/
import { useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
CartesianGrid,
Cell,
Scatter,
ScatterChart,
XAxis,
YAxis,
ZAxis,
} from "recharts";
import { TrendingUp, AlertCircle } from "lucide-react";
import { useAttendanceGradeCorrelation } from "@/lib/api";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/shared/components/charts";
import { notify } from "@/shared/lib/notify";
import { cn } from "@/shared/lib/utils";
/** 散点图数据项(从 AttendanceGradeCorrelation 派生) */
interface ScatterDataPoint {
classId: string;
className: string;
attendanceRate: number;
avgScore: number;
correlation: number;
}
/** recharts 内联对象常量:避免每次渲染创建新对象 */
const SCATTER_MARGIN = { top: 16, right: 16, bottom: 32, left: 16 };
const SCATTER_GRID_PROPS = { strokeDasharray: "4 4", strokeOpacity: 0.4 };
const TOOLTIP_CURSOR = { strokeDasharray: "3 3" };
const Z_AXIS_RANGE: [number, number] = [60, 60];
const AXIS_LABEL_STYLE = { fontSize: 12, fill: "hsl(var(--muted-foreground))" };
const SCATTER_DOMAIN: [number, number] = [0, 100];
/** 相关性强弱阈值 */
const CORRELATION_STRONG = 0.7;
const CORRELATION_WEAK = 0.4;
/** 相关性强弱等级对应的颜色(使用 CSS 变量) */
const CORRELATION_COLORS = {
strong: "hsl(var(--chart-2))",
medium: "hsl(var(--chart-4))",
weak: "hsl(var(--chart-1))",
} as const;
/** 相关性强弱等级 */
type CorrelationTier = "strong" | "medium" | "weak";
const chartConfig: ChartConfig = {
classes: {
label: "Classes",
},
};
/**
* 考勤-成绩关联分析卡:展示班级出勤率与平均成绩的关联性。
*
* 内部调用 useAttendanceGradeCorrelation hook三态loading / error / data。
* 数据态展示汇总统计 + 散点图 + 班级明细表格。
*/
export function AttendanceGradeCorrelationCard(): React.ReactElement {
const t = useTranslations("admin.attendance.gradeCorrelation");
const { data, loading, error } = useAttendanceGradeCorrelation();
useEffect(() => {
if (error) {
notify.error(t("errorNotification"));
}
}, [error, t]);
const scatterData = useMemo<ScatterDataPoint[]>(() => {
if (!data) return [];
return data.map((item) => ({
classId: item.classId,
className: item.className,
attendanceRate: Math.round(item.presentRate * 100),
avgScore: Math.round(item.avgScore * 10) / 10,
correlation: item.correlation,
}));
}, [data]);
const summary = useMemo(() => {
if (!data || data.length === 0) {
return { avgCorrelation: 0, strong: 0, medium: 0, weak: 0 };
}
const valid = data.filter(
(c) =>
Number.isFinite(c.correlation) &&
c.correlation >= -1 &&
c.correlation <= 1,
);
if (valid.length === 0) {
return { avgCorrelation: 0, strong: 0, medium: 0, weak: 0 };
}
const sum = valid.reduce((acc, c) => acc + c.correlation, 0);
const avg = sum / valid.length;
let strong = 0;
let medium = 0;
let weak = 0;
for (const c of valid) {
const tier = correlationToTier(c.correlation);
if (tier === "strong") strong++;
else if (tier === "medium") medium++;
else weak++;
}
return { avgCorrelation: avg, strong, medium, weak };
}, [data]);
if (loading) {
return <CorrelationSkeleton />;
}
if (error || !data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="size-5" />
{t("title")}
</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={error ? AlertCircle : TrendingUp}
title={error ? t("errorTitle") : t("emptyTitle")}
description={error ? t("errorDescription") : t("emptyDescription")}
className="min-h-[300px]"
/>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="size-5" />
{t("title")}
</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 顶部汇总指标 */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<SummaryStat
label={t("summaryAvgCorrelation")}
value={summary.avgCorrelation.toFixed(3)}
sublabel={t("summaryAvgCorrelationDesc")}
/>
<SummaryStat
label={t("summaryStrong")}
value={String(summary.strong)}
valueClassName="text-emerald-600 dark:text-emerald-400"
/>
<SummaryStat
label={t("summaryMedium")}
value={String(summary.medium)}
valueClassName="text-amber-600 dark:text-amber-400"
/>
<SummaryStat
label={t("summaryWeak")}
value={String(summary.weak)}
valueClassName="text-destructive"
/>
</div>
{/* 散点图 */}
{scatterData.length > 0 ? (
<div>
<h4 className="mb-3 text-sm font-medium">{t("scatterTitle")}</h4>
<ChartContainer config={chartConfig} className="h-[320px] w-full">
<ScatterChart margin={SCATTER_MARGIN}>
<CartesianGrid {...SCATTER_GRID_PROPS} />
<XAxis
type="number"
dataKey="attendanceRate"
name={t("xAxisLabel")}
domain={SCATTER_DOMAIN}
tickLine={false}
axisLine={false}
tickMargin={8}
label={{
value: t("xAxisLabel"),
position: "bottom",
offset: 16,
style: AXIS_LABEL_STYLE,
}}
/>
<YAxis
type="number"
dataKey="avgScore"
name={t("yAxisLabel")}
domain={SCATTER_DOMAIN}
tickLine={false}
axisLine={false}
width={40}
/>
<ZAxis type="number" range={Z_AXIS_RANGE} />
<ChartTooltip
cursor={TOOLTIP_CURSOR}
content={
<ChartTooltipContent hideLabel formatter={renderTooltip} />
}
/>
<Scatter
name={t("scatterSeries")}
data={scatterData}
shape="circle"
>
{scatterData.map((entry) => (
<Cell
key={entry.classId}
fill={
CORRELATION_COLORS[correlationToTier(entry.correlation)]
}
fillOpacity={0.7}
/>
))}
</Scatter>
</ScatterChart>
</ChartContainer>
{/* 相关性强弱图例 */}
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs">
{(["strong", "medium", "weak"] as const).map((tier) => (
<div key={tier} className="flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: CORRELATION_COLORS[tier] }}
aria-hidden="true"
/>
<span className="text-muted-foreground">
{t(
`legend${tier.charAt(0).toUpperCase()}${tier.slice(1)}` as
"legendStrong" | "legendMedium" | "legendWeak",
)}
</span>
</div>
))}
</div>
</div>
) : (
<EmptyState
icon={AlertCircle}
title={t("emptyTitle")}
description={t("emptyDescription")}
className="min-h-[200px]"
/>
)}
{/* 班级明细表格 */}
{data.length > 0 && (
<div>
<h4 className="mb-3 text-sm font-medium">{t("detailsTitle")}</h4>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("colClass")}
</th>
<th className="p-2 text-right font-medium">
{t("colAttendanceRate")}
</th>
<th className="p-2 text-right font-medium">
{t("colAvgScore")}
</th>
<th className="p-2 text-right font-medium">
{t("colCorrelation")}
</th>
<th className="p-2 text-center font-medium">
{t("colTier")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{data.map((item) => {
const tier = correlationToTier(item.correlation);
return (
<tr key={item.classId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{item.className}</td>
<td className="p-2 text-right font-mono text-xs tabular-nums">
{formatPercent(item.presentRate)}
</td>
<td className="p-2 text-right font-mono text-xs tabular-nums">
{item.avgScore.toFixed(1)}
</td>
<td className="p-2 text-right font-mono text-xs tabular-nums">
{item.correlation.toFixed(3)}
</td>
<td className="p-2 text-center">
{renderTierBadge(tier, t)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</CardContent>
</Card>
);
}
/** 关联分析卡骨架屏。 */
function CorrelationSkeleton(): React.ReactElement {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
<Skeleton className="mt-2 h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
<Skeleton className="h-[320px] w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
);
}
/** 汇总统计项label + value + 可选副标题/颜色)。 */
function SummaryStat({
label,
value,
sublabel,
valueClassName,
}: {
label: string;
value: string;
sublabel?: string;
valueClassName?: string;
}): React.ReactElement {
return (
<div className="rounded-lg border bg-muted/30 p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={cn(
"mt-2 text-2xl font-semibold tabular-nums",
valueClassName,
)}
>
{value}
</p>
{sublabel && (
<p className="mt-1 text-xs text-muted-foreground">{sublabel}</p>
)}
</div>
);
}
/**
* 根据相关系数返回强弱等级。
* - >= 0.7 → strong强相关
* - >= 0.4 → medium中等相关
* - < 0.4 → weak弱相关
*/
function correlationToTier(correlation: number): CorrelationTier {
if (!Number.isFinite(correlation)) return "weak";
if (correlation >= CORRELATION_STRONG) return "strong";
if (correlation >= CORRELATION_WEAK) return "medium";
return "weak";
}
/**
* 将 0-1 的比率格式化为百分比字符串。
* 输入无效返回 "--"。
*/
function formatPercent(rate: number | null | undefined): string {
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
return "--";
}
return `${Math.round(rate * 100)}%`;
}
/**
* 渲染强弱等级徽章。
*/
function renderTierBadge(
tier: CorrelationTier,
t: ReturnType<typeof useTranslations>,
): React.ReactNode {
let label: string;
let className: string;
if (tier === "strong") {
label = t("badgeStrong");
className =
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30";
} else if (tier === "medium") {
label = t("badgeMedium");
className =
"bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
} else {
label = t("badgeWeak");
className = "bg-destructive/10 text-destructive border-destructive/30";
}
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold",
className,
)}
>
{label}
</span>
);
}
/**
* recharts tooltip 格式化函数(从 unknown 转换)。
*/
function renderTooltip(item: unknown): React.ReactNode {
// item is each recharts payload entry; access .payload for the original data point
const data = (item as { payload?: unknown } | null | undefined)?.payload as
ScatterDataPoint | undefined;
if (!data) return null;
return (
<div className="space-y-1">
<div className="font-medium">{data.className}</div>
<div className="text-xs text-muted-foreground">
{data.attendanceRate}%
</div>
<div className="text-xs text-muted-foreground">{data.avgScore}</div>
<div className="text-xs text-muted-foreground">
{data.correlation.toFixed(3)}
</div>
</div>
);
}

View File

@@ -0,0 +1,293 @@
"use client";
/**
* 考勤记录列表ARCHITECTURE.md §5.4 / §9.4 / §10 P5
*
* 基于 CICD 项目 src/modules/attendance/components/attendance-record-list.tsx
* 适配到 portal-shellServer Actions → Apollo Client + MSW 兜底。
*
* 数据契约:
* - adminAttendanceRecords(filter, pagination):❌ schema 无 → MSW 兜底(@contract-pending
*
* URL 分页:?page=Npage 从 1 开始;切换筛选时由父组件重置 page。
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3
*/
import { CalendarCheck } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { useTranslations } from "next-intl";
import { useAdminAttendanceRecords } from "@/lib/api";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { Skeleton } from "@/shared/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
import { cn } from "@/shared/lib/utils";
import {
attendanceStatusToBadgeClass,
attendanceStatusToKey,
formatRecordDate,
truncateNote,
} from "@/features/admin/attendance/transformations";
const STATUS_LABEL_KEYS: Record<
"present" | "absent" | "late" | "leave",
string
> = {
present: "statusPresent",
absent: "statusAbsent",
late: "statusLate",
leave: "statusLeave",
};
/** 每页条数 */
const PAGE_SIZE = 10;
/** 分页组件最多展示的页码按钮数(奇数,便于左右对称) */
const MAX_PAGE_BUTTONS = 7;
export interface AttendanceRecordsListProps {
classId?: string;
status?: string;
date?: string;
gradeId?: string;
}
export function AttendanceRecordsList({
classId,
status,
date,
gradeId,
}: AttendanceRecordsListProps): React.ReactElement {
const t = useTranslations("admin.attendance.list");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const page = Number(searchParams.get("page") ?? "1") || 1;
const filter = {
classId: classId || null,
status: status || null,
date: date || null,
gradeId: gradeId || null,
};
const { data, loading, error } = useAdminAttendanceRecords(filter, {
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
});
const records = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(Math.max(1, page), totalPages);
const updatePage = (next: number): void => {
const params = new URLSearchParams(searchParams.toString());
if (next > 1) {
params.set("page", String(next));
} else {
params.delete("page");
}
startTransition(() => {
router.push(`/shell/admin/attendance?${params.toString()}`);
});
};
if (loading) {
return (
<div className="rounded-xl border bg-card p-4">
<Skeleton className="mb-4 h-6 w-48" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
);
}
if (error) {
return (
<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>
<p className="mt-2 text-xs text-muted-foreground">{t("mswNotice")}</p>
</div>
);
}
if (records.length === 0) {
return (
<div className="rounded-xl border bg-card">
<EmptyState
icon={CalendarCheck}
title={t("recordsEmptyTitle")}
description={t("recordsEmptyDescription")}
/>
</div>
);
}
return (
<div className="rounded-xl border bg-card">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<h3 className="text-sm font-semibold">{t("recordsTitle")}</h3>
<p className="text-xs text-muted-foreground">
{t("recordsDescription")}
</p>
</div>
<span className="text-xs text-muted-foreground">
{t("recordsTotal", { count: total })}
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("colStudent")}</TableHead>
<TableHead>{t("colClass")}</TableHead>
<TableHead>{t("colDate")}</TableHead>
<TableHead>{t("colStatus")}</TableHead>
<TableHead>{t("colNote")}</TableHead>
<TableHead>{t("colRecorder")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => {
const statusKey = attendanceStatusToKey(r.status);
const statusLabelKey = STATUS_LABEL_KEYS[statusKey];
return (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell className="text-muted-foreground">
{r.className}
</TableCell>
<TableCell className="text-muted-foreground">
{formatRecordDate(r.date)}
</TableCell>
<TableCell>
<Badge
className={cn(
"capitalize",
attendanceStatusToBadgeClass(r.status),
)}
>
{t(
statusLabelKey as
| "statusPresent"
| "statusAbsent"
| "statusLate"
| "statusLeave",
)}
</Badge>
</TableCell>
<TableCell className="max-w-[240px] truncate text-muted-foreground">
{truncateNote(r.note)}
</TableCell>
<TableCell className="text-muted-foreground">
{r.recordedBy}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{totalPages > 1 ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3 text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span>{t("recordsTotal", { count: total })}</span>
<span className="text-xs">
{safePage} / {totalPages}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => updatePage(Math.max(1, safePage - 1))}
disabled={safePage <= 1}
aria-label={tCommon("button.prev")}
>
{tCommon("button.prev")}
</Button>
{buildPageList(safePage, totalPages, MAX_PAGE_BUTTONS).map(
(p, idx) =>
p === "..." ? (
<span
key={`gap-${idx}`}
className="px-2 text-xs text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === safePage ? "default" : "outline"}
size="sm"
onClick={() => updatePage(p)}
aria-current={p === safePage ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="sm"
onClick={() => updatePage(Math.min(totalPages, safePage + 1))}
disabled={safePage >= totalPages}
aria-label={tCommon("button.next")}
>
{tCommon("button.next")}
</Button>
</div>
</div>
) : null}
</div>
);
}
/**
* 构造页码列表:当总页数不超过 maxButtons 时全部展示;
* 否则展示首尾页与当前页附近页码,省略位置用 "..." 占位。
*/
function buildPageList(
current: number,
total: number,
maxButtons: number,
): Array<number | "..."> {
if (total <= maxButtons) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const half = Math.floor(maxButtons / 2);
const start = Math.max(2, current - half + 1);
const end = Math.min(total - 1, start + maxButtons - 4);
const adjustedStart =
end - start < maxButtons - 4 ? Math.max(2, end - (maxButtons - 5)) : start;
const result: Array<number | "..."> = [1];
if (adjustedStart > 2) {
result.push("...");
}
for (let p = adjustedStart; p <= end; p += 1) {
result.push(p);
}
if (end < total - 1) {
result.push("...");
}
result.push(total);
return result;
}

View File

@@ -0,0 +1,264 @@
"use client";
/**
* 班级对比卡ARCHITECTURE.md §9.4 / §11.3 DoD 三态)
*
* 数据契约:
* - classComparison():❌ schema 无 → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loading骨架屏
* - error局部降级EmptyState + 错误文案)
* - data横向柱状图 + 排名表格
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §11.3 / §11.4
*/
import { useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { GitCompare, AlertCircle } from "lucide-react";
import { useClassComparison } from "@/lib/api";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { SimpleBarChart, type BarSeries } from "@/shared/components/charts";
import { notify } from "@/shared/lib/notify";
import { cn } from "@/shared/lib/utils";
/** 班级对比项(与 GET_CLASS_COMPARISON_DOC 返回结构一致) */
interface ClassComparisonItem {
className: string;
attendanceRate: number;
totalStudents: number;
presentStudents: number;
}
/** 柱状图数据项 */
interface BarChartData {
name: string;
rate: number;
[key: string]: string | number;
}
/** 出勤率阈值0-1用于着色分级 */
const RATE_TIER_HIGH = 0.95;
const RATE_TIER_MID = 0.9;
/**
* 班级对比卡:展示多个班级的出勤率对比。
*
* 内部调用 useClassComparison hook三态loading / error / data。
* 数据态展示横向柱状图 + 排名表格 + 数据更新时间。
*/
export function ClassComparisonCard(): React.ReactElement {
const t = useTranslations("admin.attendance.classComparison");
const { data, loading, error } = useClassComparison();
useEffect(() => {
if (error) {
notify.error(t("errorNotification"));
}
}, [error, t]);
const sorted = useMemo<ClassComparisonItem[]>(() => {
if (!data) return [];
return [...data].sort((a, b) => b.attendanceRate - a.attendanceRate);
}, [data]);
const chartData = useMemo<BarChartData[]>(() => {
return sorted.map((item) => ({
name: item.className,
rate: Math.round(item.attendanceRate * 100),
}));
}, [sorted]);
const bars: BarSeries[] = [
{
dataKey: "rate",
name: t("seriesRate"),
color: "hsl(var(--chart-1))",
},
];
const updatedAt = useMemo(() => {
return new Date().toLocaleString();
}, [data]);
if (loading) {
return <ClassComparisonSkeleton />;
}
if (error || !data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GitCompare className="size-5" />
{t("title")}
</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={error ? AlertCircle : GitCompare}
title={error ? t("errorTitle") : t("emptyTitle")}
description={error ? t("errorDescription") : t("emptyDescription")}
className="min-h-[300px]"
/>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GitCompare className="size-5" />
{t("title")}
</CardTitle>
<CardDescription>
{t("description")}
<span className="ml-2 text-xs text-muted-foreground">
{t("updatedAt", { time: updatedAt })}
</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<SimpleBarChart
data={chartData}
bars={bars}
xKey="name"
yDomain={[0, 100]}
yTickFormatter={(v) => `${v}%`}
heightClassName="h-[280px]"
/>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="w-12 p-2 text-left font-medium">
{t("colRank")}
</th>
<th className="p-2 text-left font-medium">{t("colClass")}</th>
<th className="p-2 text-right font-medium">{t("colTotal")}</th>
<th className="p-2 text-right font-medium">
{t("colPresent")}
</th>
<th className="p-2 text-right font-medium">{t("colRate")}</th>
<th className="p-2 text-center font-medium">{t("colBadge")}</th>
</tr>
</thead>
<tbody className="divide-y">
{sorted.map((item, idx) => (
<tr key={item.className} className="hover:bg-muted/30">
<td className="p-2 font-medium tabular-nums">{idx + 1}</td>
<td className="p-2 font-medium">{item.className}</td>
<td className="p-2 text-right tabular-nums">
{item.totalStudents}
</td>
<td className="p-2 text-right tabular-nums">
{item.presentStudents}
</td>
<td
className={cn(
"p-2 text-right font-mono text-xs tabular-nums",
rateToColorClass(item.attendanceRate),
)}
>
{formatPercent(item.attendanceRate)}
</td>
<td className="p-2 text-center">
{renderRateBadge(item.attendanceRate, t)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
/** 班级对比卡骨架屏。 */
function ClassComparisonSkeleton(): React.ReactElement {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
<Skeleton className="mt-2 h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-[280px] w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
);
}
/**
* 将 0-1 的出勤率格式化为百分比字符串。
* 输入无效返回 "--"。
*/
function formatPercent(rate: number | null | undefined): string {
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
return "--";
}
return `${(rate * 100).toFixed(1)}%`;
}
/**
* 根据出勤率0-1返回 Tailwind 文本语义类名。
* - >= 0.95 → emerald优秀
* - >= 0.9 → amber一般
* - 其他 → destructive低出勤率
*/
function rateToColorClass(rate: number | null | undefined): string {
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
return "text-muted-foreground";
}
if (rate >= RATE_TIER_HIGH) return "text-emerald-600 dark:text-emerald-400";
if (rate >= RATE_TIER_MID) return "text-amber-600 dark:text-amber-400";
return "text-destructive";
}
/**
* 根据出勤率渲染排名徽章。
*/
function renderRateBadge(
rate: number,
t: ReturnType<typeof useTranslations>,
): React.ReactNode {
let label: string;
let className: string;
if (rate >= RATE_TIER_HIGH) {
label = t("badgeHigh");
className =
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30";
} else if (rate >= RATE_TIER_MID) {
label = t("badgeMid");
className =
"bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
} else {
label = t("badgeLow");
className = "bg-destructive/10 text-destructive border-destructive/30";
}
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold",
className,
)}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
/**
* 审计日志详情对话框ARCHITECTURE.md §7.3 / §9.4 / §10 P5
*
* 数据契约:纯展示组件,复用列表页已加载的 AuditLog / LoginLog / DataChangeLog 数据。
* 不发起新查询,避免契约未就绪时的二次 MSW 兜底。
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
* 迁移自 CICD src/modules/audit/components/audit-log-detail-dialog.tsx
*/
import { useState, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { Eye } from "lucide-react";
import type { AuditLog, LoginLog, DataChangeLog } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import {
auditActionToLabel,
auditStatusToLabel,
dataChangeActionToLabel,
formatAuditTimestamp,
loginActionToLabel,
loginStatusToLabel,
} from "@/features/admin/audit-logs/transformations";
type AuditLogItem = {
type: "audit";
item: AuditLog;
trigger?: ReactNode;
};
type LoginLogItem = {
type: "login";
item: LoginLog;
trigger?: ReactNode;
};
type DataChangeLogItem = {
type: "dataChange";
item: DataChangeLog;
trigger?: ReactNode;
};
export type AuditLogDetailDialogProps =
AuditLogItem | LoginLogItem | DataChangeLogItem;
interface DetailRowProps {
label: string;
value: unknown;
}
function DetailRow({ label, value }: DetailRowProps): ReactNode {
const display =
value === null || value === undefined
? "--"
: typeof value === "string"
? value
: typeof value === "number" || typeof value === "boolean"
? String(value)
: JSON.stringify(value, null, 2);
return (
<div className="grid grid-cols-3 gap-2 border-b border-border py-2 last:border-0">
<dt className="text-sm font-medium text-muted-foreground">{label}</dt>
<dd className="col-span-2 break-all text-sm">{display}</dd>
</div>
);
}
export function AuditLogDetailDialog(
props: AuditLogDetailDialogProps,
): React.ReactElement {
const t = useTranslations("admin.auditLogs.detail");
const [open, setOpen] = useState<boolean>(false);
const renderRows = (): ReactNode => {
if (props.type === "audit") {
const log = props.item;
return (
<>
<DetailRow label={t("userId")} value={log.userId} />
<DetailRow label={t("userName")} value={log.userName} />
<DetailRow
label={t("action")}
value={auditActionToLabel(log.action)}
/>
<DetailRow label={t("module")} value={log.resource} />
<DetailRow label={t("resourceId")} value={log.resourceId} />
<DetailRow label={t("ipAddress")} value={log.ip} />
<DetailRow
label={t("status")}
value={auditStatusToLabel(log.status ?? "")}
/>
<DetailRow label={t("errorMessage")} value={log.errorMessage} />
<DetailRow label={t("details")} value={log.details} />
<DetailRow
label={t("createdAt")}
value={formatAuditTimestamp(log.timestamp)}
/>
</>
);
}
if (props.type === "login") {
const log = props.item;
return (
<>
<DetailRow label={t("userId")} value={log.userId} />
<DetailRow label={t("userName")} value={log.userName} />
<DetailRow
label={t("action")}
value={loginActionToLabel(log.action)}
/>
<DetailRow
label={t("status")}
value={loginStatusToLabel(log.status)}
/>
<DetailRow label={t("ipAddress")} value={log.ip} />
<DetailRow label={t("userAgent")} value={log.userAgent} />
<DetailRow label={t("errorMessage")} value={log.errorMessage} />
<DetailRow
label={t("createdAt")}
value={formatAuditTimestamp(log.timestamp)}
/>
</>
);
}
const log = props.item;
return (
<>
<DetailRow label={t("tableName")} value={log.tableName} />
<DetailRow label={t("recordId")} value={log.recordId} />
<DetailRow
label={t("action")}
value={dataChangeActionToLabel(log.action)}
/>
<DetailRow label={t("userId")} value={log.userId} />
<DetailRow label={t("userName")} value={log.userName} />
<DetailRow label={t("changes")} value={log.changes} />
<DetailRow label={t("oldValue")} value={log.oldValue} />
<DetailRow label={t("newValue")} value={log.newValue} />
<DetailRow
label={t("createdAt")}
value={formatAuditTimestamp(log.timestamp)}
/>
</>
);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{props.trigger ?? (
<Button variant="ghost" size="sm" aria-label={t("viewDetail")}>
<Eye className="size-4" />
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[80vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription className="sr-only">
{t("description")}
</DialogDescription>
</DialogHeader>
<dl className="mt-2">{renderRows()}</dl>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { Download, Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import type { ExportResult } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { notify } from "@/shared/lib/notify";
import { downloadCsv } from "@/features/admin/audit-logs/transformations";
interface AuditLogExportButtonProps<TFilter> {
runExport: (filter: TFilter) => Promise<ExportResult>;
filter: TFilter;
loading: boolean;
namespace: string;
labelKey?: string;
}
export function AuditLogExportButton<TFilter>(
props: AuditLogExportButtonProps<TFilter>,
): React.ReactElement {
const {
runExport,
filter,
loading,
namespace,
labelKey = "exportCsv",
} = props;
const t = useTranslations(namespace);
const tCommon = useTranslations("common");
const [exporting, setExporting] = useState(false);
const handleExport = async (): Promise<void> => {
setExporting(true);
try {
const result = await runExport(filter);
if (result.count === 0) {
notify.info(t("exportEmpty"));
return;
}
const ok = downloadCsv(result.filename, result.csv);
if (ok) {
notify.success(t("exportSuccess", { count: result.count }));
} else {
notify.error(tCommon("error.loadFailed", { message: "" }));
}
} catch (err) {
notify.error(t("exportFailed", { message: String(err) }));
} finally {
setExporting(false);
}
};
const isLoading = loading || exporting;
return (
<Button
onClick={handleExport}
variant="outline"
size="sm"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
{t(labelKey)}
</Button>
);
}

View File

@@ -13,12 +13,17 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { ClipboardList, Download } from "lucide-react";
import { ClipboardList, Eye, RotateCcw } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { useTranslations } from "next-intl";
import { useAuditLogs, useAuditModuleOptions } from "@/lib/api";
import type { AuditLog } from "@/lib/api";
import {
useAuditLogs,
useAuditModuleOptions,
useExportAuditLogs,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
@@ -29,13 +34,12 @@ import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import { AuditLogDetailDialog } from "@/features/admin/audit-logs/audit-log-detail-dialog";
import { AuditLogExportButton } from "@/features/admin/audit-logs/audit-log-export-button";
import {
auditActionToLabel,
auditLogsToCsv,
auditStatusToBadgeClass,
auditStatusToLabel,
downloadCsv,
formatAuditTimestamp,
} from "@/features/admin/audit-logs/transformations";
@@ -57,6 +61,9 @@ const ACTION_OPTIONS = [
/** 每页条数 */
const PAGE_SIZE = 20;
/** 分页组件最多展示的页码按钮数(奇数,便于左右对称) */
const MAX_PAGE_BUTTONS = 7;
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
@@ -92,6 +99,9 @@ export function AuditLogsListClient(): React.ReactElement {
const items = data?.items ?? [];
// 导出 hook@contract-pending MSW 兜底CSV 由 MSW 构造返回)
const { run: runExport, loading: exporting } = useExportAuditLogs();
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
@@ -107,20 +117,20 @@ export function AuditLogsListClient(): React.ReactElement {
});
};
const handleExport = (): void => {
try {
const csv = auditLogsToCsv(items);
const ok = downloadCsv(`audit-logs-${Date.now()}.csv`, csv);
if (ok) {
notify.success(t("exportCsv"));
} else {
notify.error(tCommon("error.loadFailed", { message: "" }));
}
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
const resetFilters = (): void => {
startTransition(() => {
router.push("/shell/admin/audit-logs");
});
};
const hasFilters =
Boolean(moduleFilter) ||
Boolean(actionFilter) ||
Boolean(statusFilter) ||
Boolean(userId) ||
Boolean(startDate) ||
Boolean(endDate);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
@@ -148,10 +158,16 @@ export function AuditLogsListClient(): React.ReactElement {
description={t("description")}
icon={<ClipboardList className="size-6" />}
actions={
<Button onClick={handleExport} variant="outline" size="sm">
<Download className="size-4" />
{t("exportCsv")}
</Button>
<AuditLogExportButton
runExport={runExport}
filter={{
userId: userId || null,
action: actionFilter || null,
resource: moduleFilter || null,
}}
loading={exporting}
namespace="admin.auditLogs.list"
/>
}
filters={
<FilterBar variant="wrap">
@@ -213,6 +229,18 @@ export function AuditLogsListClient(): React.ReactElement {
aria-label={t("endDate")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
/>
{hasFilters ? (
<Button
variant="ghost"
size="sm"
onClick={resetFilters}
className="h-9"
aria-label={t("resetFilter")}
>
<RotateCcw className="size-4" />
{t("resetFilter")}
</Button>
) : null}
</FilterBar>
}
loading={loading}
@@ -221,9 +249,12 @@ export function AuditLogsListClient(): React.ReactElement {
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: data?.total ?? 0 })}</span>
</div>
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onJump={(p) => updateQuery("page", String(p))}
/>
}
>
<AuditLogsTable items={items} />
@@ -233,21 +264,12 @@ export function AuditLogsListClient(): React.ReactElement {
/**
* 审计日志列表表格(纯展示组件,对齐 §8.2 排版规范)。
* 末列追加"查看详情"按钮,复用 AuditLogDetailDialog 弹窗。
*/
function AuditLogsTable({
items,
}: {
items: ReadonlyArray<{
id: string;
userId: string;
userName: string;
action: string;
resource: string;
resourceId: string;
ip: string;
timestamp: string;
details: string;
}>;
items: ReadonlyArray<AuditLog>;
}): React.ReactElement {
const t = useTranslations("admin.auditLogs.list");
return (
@@ -263,6 +285,7 @@ function AuditLogsTable({
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
<th className="p-3 text-left font-medium">{t("colIp")}</th>
<th className="p-3 text-left font-medium">{t("colDetails")}</th>
<th className="p-3 text-left font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
@@ -278,12 +301,27 @@ function AuditLogsTable({
<td className="p-3 text-muted-foreground">{log.resource}</td>
<td className="p-3">{auditActionToLabel(log.action)}</td>
<td className="p-3">
<StatusBadge status={log.details ? "success" : ""} />
<StatusBadge status={log.status} />
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{log.ip}
</td>
<td className="p-3 text-muted-foreground">{log.details}</td>
<td className="p-3">
<AuditLogDetailDialog
type="audit"
item={log}
trigger={
<Button
variant="ghost"
size="sm"
aria-label={t("colActions")}
>
<Eye className="size-4" />
</Button>
}
/>
</td>
</tr>
))}
</tbody>
@@ -294,18 +332,129 @@ function AuditLogsTable({
/**
* 状态徽章(按状态色阶展示)。
* AuditLog 类型无独立 status 字段,根据 details 是否存在做基础推断
* 真实契约补齐后切换为 log.status
* 真实契约补齐前 status 字段可能为 undefined/null此时显示 "--" 占位符
* 不再使用 details 字段虚假推断状态(违反数据真实性原则)
*/
function StatusBadge({ status }: { status: string }): React.ReactElement {
const t = useTranslations("admin.auditLogs.list");
const label = status ? auditStatusToLabel(status) : "--";
const cls = auditStatusToBadgeClass(status || "unknown");
function StatusBadge({
status,
}: {
status: string | null | undefined;
}): React.ReactElement {
const safeStatus = status ?? "";
const label = safeStatus ? auditStatusToLabel(safeStatus) : "--";
const cls = auditStatusToBadgeClass(safeStatus || "unknown");
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label === "--" ? t("allStatuses") : label}
{label}
</span>
);
}
/**
* 分页组件(页码列表 + 跳转按钮 + total/totalPages 显示)。
* 依赖 URL ?page=N 状态,由父组件控制路由跳转。
* 页码按钮策略:当 totalPages ≤ MAX_PAGE_BUTTONS 时全量展示;
* 超过时展示首尾页 + 当前页附近的页码(含省略号占位)。
*/
function Pagination({
page,
pageSize,
total,
onJump,
}: {
page: number;
pageSize: number;
total: number;
onJump: (page: number) => void;
}): React.ReactElement {
const t = useTranslations("admin.auditLogs.list");
const tCommon = useTranslations("common");
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const canPrev = page > 1;
const canNext = page < totalPages;
const pages = buildPageList(page, totalPages, MAX_PAGE_BUTTONS);
return (
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span>{t("total", { count: total })}</span>
<span className="text-xs">{t("pageOf", { page, totalPages })}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onJump(Math.max(1, page - 1))}
disabled={!canPrev}
aria-label={tCommon("button.prev")}
>
{tCommon("button.prev")}
</Button>
{pages.map((p, idx) =>
p === "..." ? (
<span
key={`gap-${idx}`}
className="px-2 text-xs text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="sm"
onClick={() => onJump(p)}
aria-current={p === page ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="sm"
onClick={() => onJump(Math.min(totalPages, page + 1))}
disabled={!canNext}
aria-label={tCommon("button.next")}
>
{tCommon("button.next")}
</Button>
</div>
</div>
);
}
/**
* 构造页码列表:当总页数不超过 maxButtons 时全部展示;
* 否则展示首尾页与当前页附近页码,省略位置用 "..." 占位。
*/
function buildPageList(
current: number,
total: number,
maxButtons: number,
): Array<number | "..."> {
if (total <= maxButtons) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const half = Math.floor(maxButtons / 2);
const start = Math.max(2, current - half + 1);
const end = Math.min(total - 1, start + maxButtons - 4);
const adjustedStart =
end - start < maxButtons - 4 ? Math.max(2, end - (maxButtons - 5)) : start;
const result: Array<number | "..."> = [1];
if (adjustedStart > 2) {
result.push("...");
}
for (let p = adjustedStart; p <= end; p += 1) {
result.push(p);
}
if (end < total - 1) {
result.push("...");
}
result.push(total);
return result;
}

View File

@@ -0,0 +1,125 @@
"use client";
/**
* 审计日志分页组件ARCHITECTURE.md §7.3 列表页 / §11.3 DoD
*
* 共享分页组件,供 audit-logs / login-logs / data-changes 列表页复用。
* 依赖 URL ?page=N 状态,由父组件控制路由跳转。
*
* 页码按钮策略:当 totalPages ≤ MAX_PAGE_BUTTONS 时全量展示;
* 超过时展示首尾页 + 当前页附近的页码(含省略号占位)。
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §11.3 / §11.4
*/
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/ui/button";
/** 分页组件最多展示的页码按钮数(奇数,便于左右对称) */
const MAX_PAGE_BUTTONS = 7;
interface AuditLogsPaginationProps {
page: number;
pageSize: number;
total: number;
onJump: (page: number) => void;
/** next-intl 命名空间,需包含 total / pageOf 键 */
namespace: string;
}
/**
* 审计日志分页组件(页码列表 + 跳转按钮 + total/totalPages 显示)。
*/
export function AuditLogsPagination(
props: AuditLogsPaginationProps,
): React.ReactElement {
const { page, pageSize, total, onJump, namespace } = props;
const t = useTranslations(namespace);
const tCommon = useTranslations("common");
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const canPrev = page > 1;
const canNext = page < totalPages;
const pages = buildPageList(page, totalPages, MAX_PAGE_BUTTONS);
return (
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span>{t("total", { count: total })}</span>
<span className="text-xs">{t("pageOf", { page, totalPages })}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onJump(Math.max(1, page - 1))}
disabled={!canPrev}
aria-label={tCommon("button.prev")}
>
{tCommon("button.prev")}
</Button>
{pages.map((p, idx) =>
p === "..." ? (
<span
key={`gap-${idx}`}
className="px-2 text-xs text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="sm"
onClick={() => onJump(p)}
aria-current={p === page ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="sm"
onClick={() => onJump(Math.min(totalPages, page + 1))}
disabled={!canNext}
aria-label={tCommon("button.next")}
>
{tCommon("button.next")}
</Button>
</div>
</div>
);
}
/**
* 构造页码列表:当总页数不超过 maxButtons 时全部展示;
* 否则展示首尾页与当前页附近页码,省略位置用 "..." 占位。
*/
function buildPageList(
current: number,
total: number,
maxButtons: number,
): Array<number | "..."> {
if (total <= maxButtons) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const half = Math.floor(maxButtons / 2);
const start = Math.max(2, current - half + 1);
const end = Math.min(total - 1, start + maxButtons - 4);
const adjustedStart =
end - start < maxButtons - 4 ? Math.max(2, end - (maxButtons - 5)) : start;
const result: Array<number | "..."> = [1];
if (adjustedStart > 2) {
result.push("...");
}
for (let p = adjustedStart; p <= end; p += 1) {
result.push(p);
}
if (end < total - 1) {
result.push("...");
}
result.push(total);
return result;
}

View File

@@ -9,11 +9,21 @@
* - dataChangeActionStats():❌ schema 无 → MSW 兜底
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ empty
* 区块级错误边界stats / trend / distribution / retention 各自包 SectionErrorBoundary
* 单点渲染失败不影响其他区块。
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { BarChart3 } from "lucide-react";
import Link from "next/link";
import {
Activity,
AlertTriangle,
Database,
FileText,
KeyRound,
} from "lucide-react";
import { useTranslations } from "next-intl";
import type { ReactNode } from "react";
import {
useAuditOverviewStats,
@@ -26,6 +36,9 @@ import {
DetailPageShell,
DetailPageSkeleton,
} from "@/shared/components/page-templates";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell";
import { AuditRetentionSettings } from "@/features/admin/audit-logs/audit-retention-settings";
import {
dataChangeActionToLabel,
formatAuditDate,
@@ -47,24 +60,18 @@ export function AuditOverviewClient(): React.ReactElement {
loading: statsLoading,
error: statsError,
} = useAuditOverviewStats();
const { data: trend, loading: trendLoading } = useAuditTrend(7);
const { data: distribution, loading: distLoading } =
useDataChangeActionStats();
const {
data: trend,
loading: trendLoading,
error: trendError,
} = useAuditTrend(7);
const {
data: distribution,
loading: distLoading,
error: distError,
} = useDataChangeActionStats();
const isLoading = statsLoading || trendLoading || distLoading;
const hasError = Boolean(statsError);
if (hasError) {
return (
<DetailPageShell title={t("title")} description={t("description")}>
<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(statsError) })}
</p>
</div>
</DetailPageShell>
);
}
if (isLoading) {
return (
@@ -78,75 +85,179 @@ export function AuditOverviewClient(): React.ReactElement {
const distData = distribution ?? [];
const distTotal = getDistributionTotal(distData);
const maxCount = getMaxTrendCount(trendData);
const failedLoginsToday = stats?.failedLoginsToday ?? 0;
return (
<DetailPageShell title={t("title")} description={t("description")}>
{/* 统计卡片 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title={t("statsTotal")}
value={stats?.totalLogs ?? 0}
icon={BarChart3}
{/* 统计卡片4 张分别对应 auditEventsToday / failedLoginsToday / dataChangesToday / totalAuditLogs */}
<SectionErrorBoundary title={t("sectionError")}>
{statsError ? (
<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(statsError) })}
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title={t("auditEventsToday")}
value={stats?.auditEventsToday ?? 0}
icon={Activity}
href="/shell/admin/audit-logs"
description={t("quickLinkAuditLogsDesc")}
valueClassName="text-blue-600 dark:text-blue-400"
/>
<StatCard
title={t("failedLoginsToday")}
value={failedLoginsToday}
icon={AlertTriangle}
href="/shell/admin/audit-logs/login-logs"
description={t("quickLinkLoginLogsDesc")}
highlight={failedLoginsToday > 0}
valueClassName="text-red-600 dark:text-red-400"
/>
<StatCard
title={t("dataChangesToday")}
value={stats?.dataChangesToday ?? 0}
icon={Database}
href="/shell/admin/audit-logs/data-changes"
description={t("quickLinkDataChangesDesc")}
valueClassName="text-emerald-600 dark:text-emerald-400"
/>
<StatCard
title={t("totalAuditLogs")}
value={stats?.totalLogs ?? 0}
icon={FileText}
href="/shell/admin/audit-logs"
description={t("sectionStats")}
valueClassName="text-purple-600 dark:text-purple-400"
/>
</div>
)}
</SectionErrorBoundary>
{/* 快捷入口卡片 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<QuickLinkCard
href="/shell/admin/audit-logs"
icon={<FileText className="size-5" />}
title={t("quickLinkAuditLogs")}
description={t("quickLinkAuditLogsDesc")}
/>
<StatCard
title={t("statsToday")}
value={stats?.totalToday ?? 0}
icon={BarChart3}
<QuickLinkCard
href="/shell/admin/audit-logs/login-logs"
icon={<KeyRound className="size-5" />}
title={t("quickLinkLoginLogs")}
description={t("quickLinkLoginLogsDesc")}
/>
<StatCard
title={t("statsErrors")}
value={stats?.totalErrors ?? 0}
icon={BarChart3}
/>
<StatCard
title={t("statsUsers")}
value={stats?.totalUsers ?? 0}
icon={BarChart3}
<QuickLinkCard
href="/shell/admin/audit-logs/data-changes"
icon={<Database className="size-5" />}
title={t("quickLinkDataChanges")}
description={t("quickLinkDataChangesDesc")}
/>
</div>
{/* 近 7 天趋势 - 简易柱状图 */}
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">{t("trendTitle")}</h2>
{trendData.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("emptyTrend")}
</p>
) : (
<SectionErrorBoundary title={t("sectionError")}>
{trendError ? (
<ChartErrorCard title={t("trendTitle")} error={trendError} />
) : (
<ChartCardShell
title={t("trendTitle")}
isEmpty={trendData.length === 0}
emptyTitle={t("chartEmpty")}
emptyDescription={t("chartEmpty")}
>
<TrendBarChart
data={trendData}
maxCount={maxCount}
labelLast7={t("trendLast7Days")}
/>
)}
</CardContent>
</Card>
</ChartCardShell>
)}
</SectionErrorBoundary>
{/* 数据变更动作分布 - 简易饼图 */}
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("distributionTitle")}
</h2>
{distData.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("emptyTrend")}
</p>
) : (
<SectionErrorBoundary title={t("sectionError")}>
{distError ? (
<ChartErrorCard title={t("distributionTitle")} error={distError} />
) : (
<ChartCardShell
title={t("distributionTitle")}
isEmpty={distData.length === 0}
emptyTitle={t("chartEmpty")}
emptyDescription={t("chartEmpty")}
>
<DistributionChart
data={distData}
total={distTotal}
colAction={t("distributionAction")}
colCount={t("distributionCount")}
/>
)}
</CardContent>
</Card>
</ChartCardShell>
)}
</SectionErrorBoundary>
{/* 保留策略设置 */}
<SectionErrorBoundary title={t("sectionError")}>
<AuditRetentionSettings />
</SectionErrorBoundary>
</DetailPageShell>
);
}
/**
* 图表错误卡片:区块级错误时展示标题 + 错误信息。
* 替代旧版本地 ChartCardShell 的 error 分支。
*/
function ChartErrorCard({
title,
error,
}: {
title: string;
error: unknown;
}): React.ReactElement {
return (
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">{title}</h2>
<p className="py-8 text-center text-sm text-destructive">
{String(error)}
</p>
</CardContent>
</Card>
);
}
/**
* 快捷入口卡片hover 动画)。
*/
function QuickLinkCard({
href,
icon,
title,
description,
}: {
href: string;
icon: ReactNode;
title: string;
description: string;
}): React.ReactElement {
return (
<Link
href={href}
className="group block rounded-xl border bg-card p-5 transition-all hover:border-primary/40 hover:shadow-md"
>
<div className="mb-3 inline-flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-transform group-hover:scale-110">
{icon}
</div>
<h3 className="mb-1 text-base font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</Link>
);
}
/**
* 趋势柱状图(纯 CSS 实现,不引入图表库)。
*/

View File

@@ -0,0 +1,233 @@
"use client";
/**
* 审计日志保留期设置卡ARCHITECTURE.md §9.4 / §10 P5
*
* 数据契约:
* - getAuditRetentionConfig():❌ schema 无 → MSW 兜底(@contract-pending
* - saveAuditRetentionConfig(input):❌ schema 无 → MSW 兜底
* - purgeExpiredAuditLogs(days):❌ schema 无 → MSW 兜底
*
* 功能:
* - 配置审计日志保留天数MIN_RETENTION_DAYS ~ MAX_RETENTION_DAYS
* - 配置登录日志独立保留天数(默认 365 天,用于安全取证)
* - 自动清理开关
* - 手动触发清理(含确认弹窗)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5 / §11.3 / §11.4
* 迁移自 CICD src/modules/audit/components/audit-retention-settings.tsx
*/
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Loader2, Save, Trash2 } from "lucide-react";
import type { AuditRetentionConfig } from "@/lib/api";
import {
useAuditRetentionConfig,
useSaveAuditRetentionConfig,
usePurgeExpiredAuditLogs,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Switch } from "@/shared/components/ui/switch";
import { notify } from "@/shared/lib/notify";
export const MIN_RETENTION_DAYS = 7;
export const MAX_RETENTION_DAYS = 3650;
export const DEFAULT_RETENTION_DAYS = 90;
export const DEFAULT_LOGIN_LOG_RETENTION_DAYS = 365;
export function AuditRetentionSettings(): React.ReactElement {
const t = useTranslations("admin.auditLogs.retention");
const tCommon = useTranslations("common");
const { data: loadedConfig, loading: isLoading } = useAuditRetentionConfig();
const { run: saveConfig, loading: isSaving } = useSaveAuditRetentionConfig();
const { run: purgeLogs, loading: isPurging } = usePurgeExpiredAuditLogs();
const [config, setConfig] = useState<AuditRetentionConfig | null>(null);
// 同步加载的配置到本地状态
useEffect(() => {
if (loadedConfig) {
setConfig(loadedConfig);
}
}, [loadedConfig]);
const handleSave = async (): Promise<void> => {
if (!config) return;
try {
await saveConfig(config);
notify.success(t("saveSuccess"));
} catch (err: unknown) {
notify.error(tCommon("error.operationFailed", { message: String(err) }));
}
};
const handlePurge = async (): Promise<void> => {
if (!config) return;
const confirmed = window.confirm(t("purgeConfirm"));
if (!confirmed) return;
try {
const result = await purgeLogs(
config.retentionDays,
config.loginLogRetentionDays,
);
notify.success(
t("purgeSuccess", {
auditLogsDeleted: result.auditLogsDeleted,
loginLogsDeleted: result.loginLogsDeleted,
dataChangeLogsDeleted: result.dataChangeLogsDeleted,
}),
);
} catch (err: unknown) {
notify.error(tCommon("error.operationFailed", { message: String(err) }));
}
};
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
</CardContent>
</Card>
);
}
if (!config) {
return (
<Card>
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t("loadFailed")}</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 审计日志保留天数 */}
<div className="space-y-2">
<Label htmlFor="retentionDays">{t("retentionDays")}</Label>
<Input
id="retentionDays"
type="number"
min={MIN_RETENTION_DAYS}
max={MAX_RETENTION_DAYS}
value={config.retentionDays}
onChange={(e) => {
const val = Number(e.target.value);
if (Number.isFinite(val)) {
setConfig({ ...config, retentionDays: val });
}
}}
className="w-32"
aria-describedby="retentionDaysHelp"
/>
<p id="retentionDaysHelp" className="text-xs text-muted-foreground">
{t("retentionDaysDescription")}
</p>
</div>
{/* 登录日志保留天数(独立于审计日志,默认 365 天) */}
<div className="space-y-2">
<Label htmlFor="loginLogRetentionDays">
{t("loginLogRetentionDays")}
</Label>
<Input
id="loginLogRetentionDays"
type="number"
min={MIN_RETENTION_DAYS}
max={MAX_RETENTION_DAYS}
value={config.loginLogRetentionDays}
onChange={(e) => {
const val = Number(e.target.value);
if (Number.isFinite(val)) {
setConfig({ ...config, loginLogRetentionDays: val });
}
}}
className="w-32"
aria-describedby="loginLogRetentionDaysHelp"
/>
<p
id="loginLogRetentionDaysHelp"
className="text-xs text-muted-foreground"
>
{t("loginLogRetentionDaysDescription")}
</p>
</div>
{/* 自动清理开关 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="autoCleanup">{t("autoCleanupEnabled")}</Label>
<p className="text-xs text-muted-foreground">
{t("autoCleanupEnabledDescription")}
</p>
</div>
<Switch
id="autoCleanup"
checked={config.autoCleanupEnabled}
onCheckedChange={(checked) =>
setConfig({ ...config, autoCleanupEnabled: checked })
}
aria-label={t("autoCleanupEnabled")}
/>
</div>
{/* 操作按钮 */}
<div className="flex gap-3">
<Button
type="button"
onClick={() => void handleSave()}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
{t("save")}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void handlePurge()}
disabled={isPurging}
>
{isPurging ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Trash2 className="mr-2 size-4" />
)}
{t("purge")}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -8,24 +8,25 @@
* - dataChangeTableOptions():❌ schema 无 → MSW 兜底
* - dataChangeStats():❌ schema 无 → MSW 兜底
*
* URL 状态:?page=&table=&action=&userId=
* URL 状态:?page=&table=&action=&userId=&startDate=&endDate=
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { Database, Download } from "lucide-react";
import { ChevronDown, ChevronRight, Database, RotateCcw } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import type { DataChangeLog } from "@/lib/api";
import {
useDataChangeLogs,
useDataChangeStats,
useDataChangeTableOptions,
useExportDataChanges,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
FilterBar,
@@ -35,12 +36,14 @@ import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell";
import { AuditLogDetailDialog } from "@/features/admin/audit-logs/audit-log-detail-dialog";
import { AuditLogExportButton } from "@/features/admin/audit-logs/audit-log-export-button";
import { AuditLogsPagination } from "@/features/admin/audit-logs/audit-logs-pagination";
import {
dataChangeActionToBadgeClass,
dataChangeActionToLabel,
dataChangeLogsToCsv,
downloadCsv,
formatAuditTimestamp,
} from "@/features/admin/audit-logs/transformations";
@@ -68,6 +71,8 @@ export function DataChangesClient(): React.ReactElement {
const tableFilter = searchParams.get("table") ?? "";
const actionFilter = searchParams.get("action") ?? "";
const userId = searchParams.get("userId") ?? "";
const startDate = searchParams.get("startDate") ?? "";
const endDate = searchParams.get("endDate") ?? "";
// @contract-pendingMSW 兜底
const { data: tableOptions } = useDataChangeTableOptions();
@@ -77,6 +82,8 @@ export function DataChangesClient(): React.ReactElement {
tableName: tableFilter || null,
action: actionFilter || null,
userId: userId || null,
startDate: startDate || null,
endDate: endDate || null,
},
{ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE },
);
@@ -84,6 +91,9 @@ export function DataChangesClient(): React.ReactElement {
const items = data?.items ?? [];
const statsData = stats ?? [];
// 导出 hook@contract-pending MSW 兜底CSV 由 MSW 构造返回)
const { run: runExport, loading: exporting } = useExportDataChanges();
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
@@ -99,20 +109,19 @@ export function DataChangesClient(): React.ReactElement {
});
};
const handleExport = (): void => {
try {
const csv = dataChangeLogsToCsv(items);
const ok = downloadCsv(`data-change-logs-${Date.now()}.csv`, csv);
if (ok) {
notify.success(t("exportCsv"));
} else {
notify.error(tCommon("error.loadFailed", { message: "" }));
}
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
const resetFilters = (): void => {
startTransition(() => {
router.push("/shell/admin/audit-logs/data-changes");
});
};
const hasFilters =
Boolean(tableFilter) ||
Boolean(actionFilter) ||
Boolean(userId) ||
Boolean(startDate) ||
Boolean(endDate);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
@@ -140,10 +149,18 @@ export function DataChangesClient(): React.ReactElement {
description={t("description")}
icon={<Database className="size-6" />}
actions={
<Button onClick={handleExport} variant="outline" size="sm">
<Download className="size-4" />
{t("exportCsv")}
</Button>
<AuditLogExportButton
runExport={runExport}
filter={{
tableName: tableFilter || null,
action: actionFilter || null,
userId: userId || null,
startDate: startDate || null,
endDate: endDate || null,
}}
loading={exporting}
namespace="admin.auditLogs.dataChanges"
/>
}
filters={
<FilterBar variant="wrap">
@@ -178,6 +195,32 @@ export function DataChangesClient(): React.ReactElement {
</option>
))}
</select>
<input
type="date"
value={startDate}
onChange={(e) => updateQuery("startDate", e.target.value)}
aria-label={t("startDate")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
/>
<input
type="date"
value={endDate}
onChange={(e) => updateQuery("endDate", e.target.value)}
aria-label={t("endDate")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
/>
{hasFilters ? (
<Button
variant="ghost"
size="sm"
onClick={resetFilters}
className="h-9"
aria-label={t("resetFilter")}
>
<RotateCcw className="size-4" />
{t("resetFilter")}
</Button>
) : null}
</FilterBar>
}
loading={loading}
@@ -186,16 +229,21 @@ export function DataChangesClient(): React.ReactElement {
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: data?.total ?? 0 })}</span>
</div>
<SectionErrorBoundary title={t("title")}>
<AuditLogsPagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onJump={(p) => updateQuery("page", String(p))}
namespace="admin.auditLogs.dataChanges"
/>
</SectionErrorBoundary>
}
>
{/* 变更统计卡片 */}
{/* 变更统计卡片 - 使用 ChartCardShell 统一图表容器 */}
{statsData.length > 0 ? (
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">{t("statsTitle")}</h2>
<SectionErrorBoundary title={t("statsTitle")}>
<ChartCardShell title={t("statsTitle")}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
@@ -226,38 +274,39 @@ export function DataChangesClient(): React.ReactElement {
</tbody>
</table>
</div>
</CardContent>
</Card>
</ChartCardShell>
</SectionErrorBoundary>
) : null}
<DataChangesTable items={items} />
<SectionErrorBoundary title={t("title")}>
<DataChangesTable items={items} />
</SectionErrorBoundary>
</ListPageShell>
);
}
/**
* 数据变更日志表格(纯展示组件)。
* 支持行内展开 diffoldValue/newValue 对比),末列提供详情对话框入口。
*/
function DataChangesTable({
items,
}: {
items: ReadonlyArray<{
id: string;
tableName: string;
recordId: string;
action: string;
userId: string;
userName: string;
changes: string;
timestamp: string;
}>;
items: ReadonlyArray<DataChangeLog>;
}): React.ReactElement {
const t = useTranslations("admin.auditLogs.dataChanges");
const [expandedId, setExpandedId] = useState<string | null>(null);
const toggleExpand = (id: string): void => {
setExpandedId((prev) => (prev === id ? null : id));
};
return (
<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" aria-label="expand" />
<th className="p-3 text-left font-medium">{t("colTimestamp")}</th>
<th className="p-3 text-left font-medium">{t("colTable")}</th>
<th className="p-3 text-left font-medium">{t("colRecordId")}</th>
@@ -265,36 +314,144 @@ function DataChangesTable({
<th className="p-3 text-left font-medium">{t("colUserId")}</th>
<th className="p-3 text-left font-medium">{t("colUserName")}</th>
<th className="p-3 text-left font-medium">{t("colChanges")}</th>
<th className="p-3 text-left font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((log) => (
<tr key={log.id} className="hover:bg-muted/30">
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatAuditTimestamp(log.timestamp)}
</td>
<td className="p-3 font-medium">{log.tableName}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{log.recordId}
</td>
<td className="p-3">
<DataChangeActionBadge action={log.action} />
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{log.userId}
</td>
<td className="p-3">{log.userName}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{log.changes}
</td>
</tr>
))}
{items.map((log) => {
const isExpanded = expandedId === log.id;
const hasDiff = Boolean(log.oldValue) || Boolean(log.newValue);
return (
<RowFragment
key={log.id}
log={log}
isExpanded={isExpanded}
hasDiff={hasDiff}
onToggle={toggleExpand}
/>
);
})}
</tbody>
</table>
</div>
);
}
/**
* 单行 + 可选展开行diff 对比)。
*/
function RowFragment({
log,
isExpanded,
hasDiff,
onToggle,
}: {
log: DataChangeLog;
isExpanded: boolean;
hasDiff: boolean;
onToggle: (id: string) => void;
}): React.ReactElement {
const t = useTranslations("admin.auditLogs.dataChanges");
return (
<>
<tr className="hover:bg-muted/30">
<td className="p-3">
{hasDiff ? (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onToggle(log.id)}
aria-label={isExpanded ? t("collapseDetail") : t("expandDetail")}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</Button>
) : null}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatAuditTimestamp(log.timestamp)}
</td>
<td className="p-3 font-medium">{log.tableName}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{log.recordId}
</td>
<td className="p-3">
<DataChangeActionBadge action={log.action} />
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{log.userId}
</td>
<td className="p-3">{log.userName}</td>
<td
className="max-w-xs truncate p-3 font-mono text-xs text-muted-foreground"
title={log.changes}
>
{log.changes}
</td>
<td className="p-3">
<AuditLogDetailDialog type="dataChange" item={log} />
</td>
</tr>
{isExpanded && hasDiff ? (
<tr className="bg-muted/20">
<td />
<td colSpan={8} className="p-3">
<DiffCompare
oldValue={log.oldValue}
newValue={log.newValue}
oldLabel={t("oldValue")}
newLabel={t("newValue")}
/>
</td>
</tr>
) : null}
</>
);
}
/**
* 变更前后对比展示(左右两栏)。
*/
function DiffCompare({
oldValue,
newValue,
oldLabel,
newLabel,
}: {
oldValue: string | null | undefined;
newValue: string | null | undefined;
oldLabel: string;
newLabel: string;
}): React.ReactElement {
const formatValue = (v: string | null | undefined): string => {
if (!v) return "--";
return v;
};
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
<p className="mb-2 text-xs font-medium text-destructive">{oldLabel}</p>
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-muted-foreground">
{formatValue(oldValue)}
</pre>
</div>
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
<p className="mb-2 text-xs font-medium text-emerald-600 dark:text-emerald-400">
{newLabel}
</p>
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-muted-foreground">
{formatValue(newValue)}
</pre>
</div>
</div>
);
}
/**
* 数据变更动作徽章。
*/

View File

@@ -6,18 +6,20 @@
* 数据契约:
* - loginLogs(filter, pagination):❌ schema 无 → MSW 兜底(@contract-pending
*
* URL 状态:?page=&action=&status=&userId=
* URL 状态:?page=&action=&status=&userId=&startDate=&endDate=
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
* 迁移自 CICD src/modules/audit/components/login-log-view.tsx + login-log-table.tsx
*/
import { Download, LogIn } from "lucide-react";
import { LogIn, RotateCcw } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { useTranslations } from "next-intl";
import { useLoginLogs } from "@/lib/api";
import type { LoginLog } from "@/lib/api";
import { useLoginLogs, useExportLoginLogs } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
@@ -28,12 +30,13 @@ import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { AuditLogDetailDialog } from "@/features/admin/audit-logs/audit-log-detail-dialog";
import { AuditLogExportButton } from "@/features/admin/audit-logs/audit-log-export-button";
import { AuditLogsPagination } from "@/features/admin/audit-logs/audit-logs-pagination";
import {
downloadCsv,
formatAuditTimestamp,
loginActionToLabel,
loginLogsToCsv,
loginStatusToBadgeClass,
loginStatusToLabel,
} from "@/features/admin/audit-logs/transformations";
@@ -68,6 +71,8 @@ export function LoginLogsClient(): React.ReactElement {
const actionFilter = searchParams.get("action") ?? "";
const statusFilter = searchParams.get("status") ?? "";
const userId = searchParams.get("userId") ?? "";
const startDate = searchParams.get("startDate") ?? "";
const endDate = searchParams.get("endDate") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useLoginLogs(
@@ -75,12 +80,17 @@ export function LoginLogsClient(): React.ReactElement {
action: actionFilter || null,
status: statusFilter || null,
userId: userId || null,
startDate: startDate || null,
endDate: endDate || null,
},
{ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE },
);
const items = data?.items ?? [];
// 导出 hook@contract-pending MSW 兜底CSV 由 MSW 构造返回)
const { run: runExport, loading: exporting } = useExportLoginLogs();
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
@@ -96,20 +106,19 @@ export function LoginLogsClient(): React.ReactElement {
});
};
const handleExport = (): void => {
try {
const csv = loginLogsToCsv(items);
const ok = downloadCsv(`login-logs-${Date.now()}.csv`, csv);
if (ok) {
notify.success(t("exportCsv"));
} else {
notify.error(tCommon("error.loadFailed", { message: "" }));
}
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
const resetFilters = (): void => {
startTransition(() => {
router.push("/shell/admin/audit-logs/login-logs");
});
};
const hasFilters =
Boolean(actionFilter) ||
Boolean(statusFilter) ||
Boolean(userId) ||
Boolean(startDate) ||
Boolean(endDate);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
@@ -137,10 +146,16 @@ export function LoginLogsClient(): React.ReactElement {
description={t("description")}
icon={<LogIn className="size-6" />}
actions={
<Button onClick={handleExport} variant="outline" size="sm">
<Download className="size-4" />
{t("exportCsv")}
</Button>
<AuditLogExportButton
runExport={runExport}
filter={{
action: actionFilter || null,
status: statusFilter || null,
userId: userId || null,
}}
loading={exporting}
namespace="admin.auditLogs.loginLogs"
/>
}
filters={
<FilterBar variant="wrap">
@@ -175,6 +190,32 @@ export function LoginLogsClient(): React.ReactElement {
</option>
))}
</select>
<input
type="date"
value={startDate}
onChange={(e) => updateQuery("startDate", e.target.value)}
aria-label={t("startDate")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
/>
<input
type="date"
value={endDate}
onChange={(e) => updateQuery("endDate", e.target.value)}
aria-label={t("endDate")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
/>
{hasFilters ? (
<Button
variant="ghost"
size="sm"
onClick={resetFilters}
className="h-9"
aria-label={t("resetFilter")}
>
<RotateCcw className="size-4" />
{t("resetFilter")}
</Button>
) : null}
</FilterBar>
}
loading={loading}
@@ -183,32 +224,32 @@ export function LoginLogsClient(): React.ReactElement {
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: data?.total ?? 0 })}</span>
</div>
<SectionErrorBoundary title={t("title")}>
<AuditLogsPagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onJump={(p) => updateQuery("page", String(p))}
namespace="admin.auditLogs.loginLogs"
/>
</SectionErrorBoundary>
}
>
<LoginLogsTable items={items} />
<SectionErrorBoundary title={t("title")}>
<LoginLogsTable items={items} />
</SectionErrorBoundary>
</ListPageShell>
);
}
/**
* 登录日志表格(纯展示组件)。
* userAgent 截断显示max-w + truncateerrorMessage 行内展开显示在状态徽章下方。
*/
function LoginLogsTable({
items,
}: {
items: ReadonlyArray<{
id: string;
userId: string;
userName: string;
action: string;
status: string;
ip: string;
userAgent: string;
timestamp: string;
}>;
items: ReadonlyArray<LoginLog>;
}): React.ReactElement {
const t = useTranslations("admin.auditLogs.loginLogs");
return (
@@ -223,6 +264,7 @@ function LoginLogsTable({
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
<th className="p-3 text-left font-medium">{t("colIp")}</th>
<th className="p-3 text-left font-medium">{t("colUserAgent")}</th>
<th className="p-3 text-left font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
@@ -238,13 +280,24 @@ function LoginLogsTable({
<td className="p-3">{loginActionToLabel(log.action)}</td>
<td className="p-3">
<LoginStatusBadge status={log.status} />
{log.errorMessage ? (
<div className="mt-1 text-xs text-destructive">
{log.errorMessage}
</div>
) : null}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{log.ip}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
<td
className="max-w-[280px] truncate p-3 font-mono text-xs text-muted-foreground"
title={log.userAgent}
>
{log.userAgent}
</td>
<td className="p-3">
<AuditLogDetailDialog type="login" item={log} />
</td>
</tr>
))}
</tbody>

View File

@@ -15,7 +15,7 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import { ClipboardList, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useTranslations } from "next-intl";
@@ -26,10 +26,14 @@ import {
useCreateCoursePlan,
useGrades,
useTeacherOptions,
type CoursePlanTemplate,
type CreateCoursePlanInput,
} from "@/lib/api";
import { FormPageShell } from "@/shared/components/page-templates";
import { Button } from "@/shared/components/ui/button";
import { Select } from "@/shared/components/ui/select";
import { notify } from "@/shared/lib/notify";
import { TemplatePickerDialog } from "@/features/admin/course-plans/template-picker-dialog";
import {
ADMIN_COURSE_PLAN_STATUS_OPTIONS,
formatAdminCoursePlanStatus,
@@ -43,6 +47,7 @@ export function CoursePlanCreateClient(): React.ReactElement {
const tCommon = useTranslations("common");
const router = useRouter();
const [, startTransition] = useTransition();
const [templateOpen, setTemplateOpen] = useState(false);
// @contract-pendingMSW 兜底
const { data: grades, loading: gradesLoading } = useGrades();
@@ -60,8 +65,28 @@ export function CoursePlanCreateClient(): React.ReactElement {
const [teacherId, setTeacherId] = useState("");
const [academicYearId, setAcademicYearId] = useState("");
const [status, setStatus] = useState<string>("DRAFT");
const [semester, setSemester] = useState("");
const [syllabus, setSyllabus] = useState("");
const [objectives, setObjectives] = useState("");
const [totalHours, setTotalHours] = useState("");
const [weeklyHours, setWeeklyHours] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const handleTemplateSelect = (template: CoursePlanTemplate): void => {
// 用模板数据填充表单字段(仅填充可见字段,不强制覆盖用户已填内容)
setTitle((prev) => (prev.trim() ? prev : template.name));
setDescription((prev) => (prev.trim() ? prev : template.description));
if (template.subject) {
setSubjectId(template.subject);
}
if (template.gradeLevel) {
setGradeId(template.gradeLevel);
}
notify.success(t("templateApplied"));
};
const handleSubmit = async (): Promise<void> => {
setFormError(null);
@@ -120,6 +145,25 @@ export function CoursePlanCreateClient(): React.ReactElement {
) : undefined
}
>
{/* 从模板创建 */}
<div className="flex items-center justify-between rounded-md border border-dashed bg-muted/30 p-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">{t("fromTemplate")}</p>
<p className="text-xs text-muted-foreground">
{t("fromTemplateHint")}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setTemplateOpen(true)}
>
<Sparkles className="size-4" aria-hidden="true" />
{t("openTemplatePicker")}
</Button>
</div>
{/* 标题 */}
<FormField label={t("fieldTitle")} required>
<input
@@ -146,42 +190,40 @@ export function CoursePlanCreateClient(): React.ReactElement {
<div className="grid grid-cols-2 gap-4">
{/* 年级 */}
<FormField label={t("fieldGradeId")} required>
<select
<Select
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
onValueChange={setGradeId}
options={[
{
value: "",
label: gradesLoading ? tCommon("loading") : t("fieldGradeId"),
},
...(grades ?? []).map((g) => ({
value: g.id,
label: g.name,
})),
]}
disabled={gradesLoading}
>
<option value="">
{gradesLoading ? tCommon("loading") : t("fieldGradeId")}
</option>
{(grades ?? []).map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
/>
</FormField>
{/* 班级 */}
<FormField label={t("fieldClassId")} required>
<select
<Select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
onValueChange={setClassId}
options={[
{
value: "",
label: classesLoading ? tCommon("loading") : t("fieldClassId"),
},
...(classes ?? []).map((c) => ({
value: c.id,
label: c.name,
})),
]}
disabled={classesLoading}
>
<option value="">
{classesLoading ? tCommon("loading") : t("fieldClassId")}
</option>
{(classes ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
/>
</FormField>
</div>
@@ -200,62 +242,151 @@ export function CoursePlanCreateClient(): React.ReactElement {
{/* 教师 */}
<FormField label={t("fieldTeacherId")}>
<select
<Select
value={teacherId}
onChange={(e) => setTeacherId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
onValueChange={setTeacherId}
options={[
{
value: "",
label: teachersLoading
? tCommon("loading")
: t("fieldTeacherId"),
},
...(teachers ?? []).map((teacher) => ({
value: teacher.id,
label: teacher.name,
})),
]}
disabled={teachersLoading}
>
<option value="">
{teachersLoading ? tCommon("loading") : t("fieldTeacherId")}
</option>
{(teachers ?? []).map((teacher) => (
<option key={teacher.id} value={teacher.id}>
{teacher.name}
</option>
))}
</select>
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 学年 */}
<FormField label={t("fieldAcademicYearId")}>
<select
<Select
value={academicYearId}
onChange={(e) => setAcademicYearId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
onValueChange={setAcademicYearId}
options={[
{
value: "",
label: yearsLoading
? tCommon("loading")
: t("fieldAcademicYearId"),
},
...(academicYears ?? []).map((y) => ({
value: y.id,
label: y.name,
})),
]}
disabled={yearsLoading}
>
<option value="">
{yearsLoading ? tCommon("loading") : t("fieldAcademicYearId")}
</option>
{(academicYears ?? []).map((y) => (
<option key={y.id} value={y.id}>
{y.name}
</option>
))}
</select>
/>
</FormField>
{/* 状态 */}
<FormField label={t("fieldStatus")}>
<select
<Select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{ADMIN_COURSE_PLAN_STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{formatAdminCoursePlanStatus(s)}
</option>
))}
</select>
onValueChange={setStatus}
options={ADMIN_COURSE_PLAN_STATUS_OPTIONS.map((s) => ({
value: s,
label: formatAdminCoursePlanStatus(s),
}))}
/>
</FormField>
</div>
{/* 学期 */}
<FormField label={t("fieldSemester")}>
<input
type="text"
value={semester}
onChange={(e) => setSemester(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="2026-fall"
/>
</FormField>
<div className="grid grid-cols-2 gap-4">
{/* 总课时 */}
<FormField label={t("fieldTotalHours")}>
<input
type="number"
min={0}
value={totalHours}
onChange={(e) => setTotalHours(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="120"
/>
</FormField>
{/* 周课时 */}
<FormField label={t("fieldWeeklyHours")}>
<input
type="number"
min={0}
value={weeklyHours}
onChange={(e) => setWeeklyHours(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="6"
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 开始日期 */}
<FormField label={t("fieldStartDate")}>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
{/* 结束日期 */}
<FormField label={t("fieldEndDate")}>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
</div>
{/* 教学大纲 */}
<FormField label={t("fieldSyllabus")}>
<textarea
value={syllabus}
onChange={(e) => setSyllabus(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("fieldSyllabus")}
/>
</FormField>
{/* 教学目标 */}
<FormField label={t("fieldObjectives")}>
<textarea
value={objectives}
onChange={(e) => setObjectives(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("fieldObjectives")}
/>
</FormField>
{/* @contract-pending 提示 */}
<p className="text-xs text-muted-foreground">{t("contractPending")}</p>
{/* 模板选择对话框 */}
<TemplatePickerDialog
open={templateOpen}
onOpenChange={setTemplateOpen}
onSelect={handleTemplateSelect}
/>
</FormPageShell>
);
}

View File

@@ -12,18 +12,27 @@
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 与教师域 course-plan-detail-client 的差异
* - 管理端 scope.isAdmin=true展示全校视角字段班级/科目/教师/学年
* - 不展示教师域的"单元进度/教学目标"等教师私有字段AdminCoursePlan 仅含 content
* 周计划管理P0-5
* - 列表展示 items周次 / 主题 / 课时 / 章节 / 状态
* - 上移/下移排序(调用 reorderCoursePlanItems mutation
* - 新建/编辑/删除周计划项CoursePlanItemEditor 对话框)
* - 切换完成状态toggleCoursePlanItemCompleted mutation
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import { ClipboardList, Pencil, Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useAdminCoursePlan } from "@/lib/api";
import {
useAdminCoursePlan,
useReorderCoursePlanItems,
type AdminCoursePlanItem,
} from "@/lib/api";
import { useBulkToggleCoursePlanItems } from "@/lib/api/admin-p5";
import { useDeleteCoursePlan } from "@/lib/api/admin-p5";
import { Button } from "@/shared/components/ui/button";
import {
DetailPageShell,
@@ -31,6 +40,18 @@ import {
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
import { Badge } from "@/shared/components/ui/badge";
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog";
import { Progress } from "@/shared/components/ui/progress";
import { notify } from "@/shared/lib/notify";
import {
adminCoursePlanStatusToBadgeClass,
displayText,
@@ -39,6 +60,7 @@ import {
hasContent,
isAdminCoursePlanEditable,
} from "@/features/admin/course-plans/transformations";
import { CoursePlanItemEditor } from "@/features/admin/course-plans/course-plan-item-editor";
/**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
@@ -46,11 +68,26 @@ import {
export function CoursePlanDetailClient(): React.ReactElement {
const t = useTranslations("admin.coursePlans.detail");
const tCommon = useTranslations("common");
const router = useRouter();
const params = useParams<{ id: string }>();
const planId = params?.id ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useAdminCoursePlan(planId);
const { data, loading, error, refetch } = useAdminCoursePlan(planId);
const { run: deletePlan, loading: deleting } = useDeleteCoursePlan();
const [deleteOpen, setDeleteOpen] = useState(false);
const handleDelete = async (): Promise<void> => {
try {
await deletePlan(planId);
notify.success(t("deleteSuccess"));
setDeleteOpen(false);
router.push("/shell/admin/course-plans");
} catch (err) {
notify.error(t("deleteFailed"));
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
@@ -67,12 +104,26 @@ export function CoursePlanDetailClient(): React.ReactElement {
icon={<ClipboardList className="size-6" />}
backHref="/shell/admin/course-plans"
actions={
data && isAdminCoursePlanEditable(data.status) ? (
<Button asChild variant="outline">
<Link href={`/shell/admin/course-plans/${data.id}/edit`}>
{t("edit")}
</Link>
</Button>
data ? (
<div className="flex items-center gap-2">
{isAdminCoursePlanEditable(data.status) ? (
<Button asChild variant="outline">
<Link href={`/shell/admin/course-plans/${data.id}/edit`}>
<Pencil className="mr-2 size-4" aria-hidden="true" />
{t("edit")}
</Link>
</Button>
) : null}
<Button
variant="outline"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setDeleteOpen(true)}
disabled={deleting}
>
<Trash2 className="mr-2 size-4" aria-hidden="true" />
{t("delete")}
</Button>
</div>
) : null
}
loading={loading}
@@ -86,20 +137,52 @@ export function CoursePlanDetailClient(): React.ReactElement {
) : undefined
}
>
{data ? <CoursePlanDetailBody detail={data} /> : null}
{data ? (
<CoursePlanDetailBody
detail={data}
planId={planId}
onRefresh={() => void refetch()}
/>
) : null}
<ConfirmDeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={t("deleteTitle")}
description={t("deleteDescription")}
confirmText={t("deleteConfirm")}
cancelText={t("deleteCancel")}
onConfirm={handleDelete}
isWorking={deleting}
/>
</DetailPageShell>
);
}
/**
* 详情内容区(基本信息 + 计划内容)。
* 详情内容区(基本信息 + 计划内容 + 周计划管理)。
*/
function CoursePlanDetailBody({
detail,
planId,
onRefresh,
}: {
detail: NonNullable<ReturnType<typeof useAdminCoursePlan>["data"]>;
planId: string;
onRefresh: () => void;
}): React.ReactElement {
const t = useTranslations("admin.coursePlans.detail");
const canManage = isAdminCoursePlanEditable(detail.status);
const totalHours =
typeof detail.totalHours === "number" ? detail.totalHours : 0;
const completedHours =
typeof detail.completedHours === "number" ? detail.completedHours : 0;
const progressPercent =
totalHours > 0
? Math.min(Math.round((completedHours / totalHours) * 100), 100)
: 0;
return (
<>
<DetailSection title={t("sectionBasic")}>
@@ -124,6 +207,10 @@ function CoursePlanDetailBody({
label={t("fieldAcademicYear")}
value={displayText(detail.academicYearName)}
/>
<DetailField
label={t("fieldSemester")}
value={displayText(detail.semester)}
/>
<DetailField
label={t("fieldCreatedAt")}
value={formatAdminCoursePlanDate(detail.createdAt)}
@@ -134,6 +221,43 @@ function CoursePlanDetailBody({
/>
</DetailSection>
{/* 教学进度 */}
{totalHours > 0 ? (
<DetailSection title={t("fieldTotalHours")}>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("fieldTotalHours")}: {totalHours}
</span>
<span className="font-mono text-xs text-muted-foreground">
{completedHours}/{totalHours} ({progressPercent}%)
</span>
</div>
<Progress value={progressPercent} className="h-2" />
</div>
</DetailSection>
) : null}
{/* 计划时间 */}
{detail.startDate || detail.endDate ? (
<DetailSection title={t("fieldStartDate")}>
<DetailField
label={t("fieldStartDate")}
value={displayText(detail.startDate)}
/>
<DetailField
label={t("fieldEndDate")}
value={displayText(detail.endDate)}
/>
{typeof detail.weeklyHours === "number" ? (
<DetailField
label={t("fieldWeeklyHours")}
value={String(detail.weeklyHours)}
/>
) : null}
</DetailSection>
) : null}
<DetailSection title={t("sectionContent")}>
{hasContent(detail.content) ? (
<div className="whitespace-pre-wrap text-sm">{detail.content}</div>
@@ -141,10 +265,369 @@ function CoursePlanDetailBody({
<p className="text-sm text-muted-foreground">{t("emptyResources")}</p>
)}
</DetailSection>
{/* 教学大纲 */}
<DetailSection title={t("fieldSyllabus")}>
{detail.syllabus ? (
<div className="whitespace-pre-wrap text-sm">{detail.syllabus}</div>
) : (
<p className="text-sm text-muted-foreground">{t("emptySyllabus")}</p>
)}
</DetailSection>
{/* 教学目标 */}
<DetailSection title={t("fieldObjectives")}>
{detail.objectives ? (
<div className="whitespace-pre-wrap text-sm">{detail.objectives}</div>
) : (
<p className="text-sm text-muted-foreground">
{t("emptyObjectives")}
</p>
)}
</DetailSection>
{/* 关联链接 */}
{detail.textbooksHref || detail.homeworkHref ? (
<DetailSection title={t("sectionBasic")}>
<div className="flex flex-wrap gap-3">
{detail.textbooksHref ? (
<Button asChild variant="outline" size="sm">
<Link href={detail.textbooksHref}>{t("linkTextbooks")}</Link>
</Button>
) : null}
{detail.homeworkHref ? (
<Button asChild variant="outline" size="sm">
<Link href={detail.homeworkHref}>{t("linkHomework")}</Link>
</Button>
) : null}
</div>
</DetailSection>
) : null}
<WeeklyPlansSection
planId={planId}
items={detail.items}
canManage={canManage}
onRefresh={onRefresh}
/>
</>
);
}
/**
* 周计划管理区块P0-5
*/
function WeeklyPlansSection({
planId,
items,
canManage,
onRefresh,
}: {
planId: string;
items: AdminCoursePlanItem[];
canManage: boolean;
onRefresh: () => void;
}): React.ReactElement {
const t = useTranslations("admin.coursePlans.detail");
const tCommon = useTranslations("common");
const { run: reorderItems, loading: reordering } =
useReorderCoursePlanItems();
const { run: bulkToggle, loading: bulkLoading } =
useBulkToggleCoursePlanItems();
const [editorOpen, setEditorOpen] = useState(false);
const [editingItem, setEditingItem] = useState<
AdminCoursePlanItem | undefined
>(undefined);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 本地排序副本(按 week 升序),便于上移/下移后立即重渲染
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.week - b.week),
[items],
);
const allSelected =
sortedItems.length > 0 && selectedIds.size === sortedItems.length;
const toggleSelectAll = (): void => {
if (allSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(sortedItems.map((it) => it.id)));
}
};
const toggleSelect = (id: string): void => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const handleBulkToggle = async (isCompleted: boolean): Promise<void> => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
try {
await bulkToggle(planId, ids, isCompleted);
notify.success(t("bulkSuccess", { count: ids.length }));
setSelectedIds(new Set());
onRefresh();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const openCreateEditor = (): void => {
setEditingItem(undefined);
setEditorOpen(true);
};
const openEditEditor = (item: AdminCoursePlanItem): void => {
setEditingItem(item);
setEditorOpen(true);
};
const handleMoveUp = async (item: AdminCoursePlanItem): Promise<void> => {
const idx = sortedItems.findIndex((it) => it.id === item.id);
if (idx <= 0) return;
// 重新生成 week 序号,并将目标项与上一项交换位置
const finalOrder = sortedItems.map((it, i) => ({ id: it.id, week: i + 1 }));
const targetCurr = finalOrder[idx];
const targetPrev = finalOrder[idx - 1];
if (targetCurr && targetPrev) {
finalOrder[idx - 1] = { id: targetCurr.id, week: idx };
finalOrder[idx] = { id: targetPrev.id, week: idx + 1 };
}
try {
await reorderItems(planId, finalOrder);
notify.success(t("reorderSuccess"));
onRefresh();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleMoveDown = async (item: AdminCoursePlanItem): Promise<void> => {
const idx = sortedItems.findIndex((it) => it.id === item.id);
if (idx < 0 || idx >= sortedItems.length - 1) return;
const finalOrder = sortedItems.map((it, i) => ({ id: it.id, week: i + 1 }));
const targetCurr = finalOrder[idx];
const targetNext = finalOrder[idx + 1];
if (targetCurr && targetNext) {
finalOrder[idx] = { id: targetNext.id, week: idx + 1 };
finalOrder[idx + 1] = { id: targetCurr.id, week: idx + 2 };
}
try {
await reorderItems(planId, finalOrder);
notify.success(t("reorderSuccess"));
onRefresh();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
return (
<DetailSection title={t("sectionSchedule")}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{t("weekPlansHint", { count: sortedItems.length })}
</p>
{canManage ? (
<Button
type="button"
size="sm"
onClick={openCreateEditor}
disabled={reordering}
>
<Plus className="mr-2 size-4" aria-hidden="true" />
{t("addWeekPlan")}
</Button>
) : null}
</div>
{/* 批量操作工具栏 */}
{canManage && sortedItems.length > 0 ? (
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={toggleSelectAll}
disabled={bulkLoading}
>
{allSelected ? t("bulkClear") : t("bulkSelectAll")}
</Button>
{selectedIds.size > 0 ? (
<>
<span className="text-xs text-muted-foreground">
{t("bulkSelected", { count: selectedIds.size })}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleBulkToggle(true)}
disabled={bulkLoading}
>
{t("bulkMarkComplete")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleBulkToggle(false)}
disabled={bulkLoading}
>
{t("bulkMarkIncomplete")}
</Button>
</>
) : null}
</div>
) : null}
{sortedItems.length === 0 ? (
<div className="rounded-md border p-6 text-center text-sm text-muted-foreground">
{t("emptyWeekPlans")}
{canManage ? t("emptyWeekPlansCta") : ""}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{canManage ? (
<TableHead className="w-10">
<input
type="checkbox"
checked={allSelected}
onChange={toggleSelectAll}
className="size-4 rounded border-input"
aria-label={t("bulkSelectAll")}
/>
</TableHead>
) : null}
<TableHead className="w-16">{t("colWeek")}</TableHead>
<TableHead>{t("colTopic")}</TableHead>
<TableHead className="w-20">{t("colHours")}</TableHead>
<TableHead className="w-28">{t("colChapter")}</TableHead>
<TableHead className="w-24">{t("colStatus")}</TableHead>
{canManage ? (
<TableHead className="w-32">{t("colActions")}</TableHead>
) : null}
</TableRow>
</TableHeader>
<TableBody>
{sortedItems.map((item, idx) => (
<TableRow key={item.id}>
{canManage ? (
<TableCell>
<input
type="checkbox"
checked={selectedIds.has(item.id)}
onChange={() => toggleSelect(item.id)}
className="size-4 rounded border-input"
aria-label={t("bulkSelectAll")}
/>
</TableCell>
) : null}
<TableCell className="font-medium">{item.week}</TableCell>
<TableCell>
<div className="space-y-1">
<p className="font-medium">{item.topic}</p>
{item.content ? (
<p className="text-xs text-muted-foreground line-clamp-2">
{item.content}
</p>
) : null}
{item.notes ? (
<p className="text-xs text-muted-foreground italic">
{t("notesLabel", { notes: item.notes })}
</p>
) : null}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{item.hours}
</TableCell>
<TableCell className="text-muted-foreground">
{displayText(item.textbookChapter)}
</TableCell>
<TableCell>
<Badge
variant={item.isCompleted ? "default" : "secondary"}
>
{item.isCompleted
? t("statusCompleted")
: t("statusPending")}
</Badge>
</TableCell>
{canManage ? (
<TableCell>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => void handleMoveUp(item)}
disabled={reordering || idx === 0}
aria-label={t("moveUpAria", { week: item.week })}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => void handleMoveDown(item)}
disabled={
reordering || idx === sortedItems.length - 1
}
aria-label={t("moveDownAria", { week: item.week })}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => openEditEditor(item)}
disabled={reordering}
>
{t("editItem")}
</Button>
</div>
</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<CoursePlanItemEditor
planId={planId}
item={editingItem}
mode={editingItem ? "edit" : "create"}
open={editorOpen}
onOpenChange={setEditorOpen}
onSuccess={onRefresh}
/>
</div>
</DetailSection>
);
}
/**
* 状态徽章(按状态色阶展示)。
*/

View File

@@ -8,13 +8,14 @@
* - mutation updateCoursePlan(input) ❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending
* - 选项数据 grades / adminClasses / teacherOptions / academicYearsMSW 兜底
* - 选项数据 subjectOptions❌ 暂无 hook → 文本输入兜底(@contract-pending
* - 周次教学进度:❌ schema 无 → 本地 state + 默认骨架(@contract-pending保存时暂不同步
*
* 三态规范§11.3 DoD
* - loadingFormPageSkeleton加载预填数据
* - errorerrorSummary 表单级错误
* - successnotify.success + router.push 回详情页
*
* 与 course-plan-create-client 的差异:预填表单 + 调用 updateCoursePlan(input)
* 与 course-plan-create-client 的差异:预填表单 + 调用 updateCoursePlan(input) + 周次排序
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
@@ -34,11 +35,39 @@ import {
} from "@/lib/api";
import { FormPageShell } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
import { Select } from "@/shared/components/ui/select";
import {
ADMIN_COURSE_PLAN_STATUS_OPTIONS,
formatAdminCoursePlanStatus,
isAdminCoursePlanEditable,
} from "@/features/admin/course-plans/transformations";
import {
SortableWeekRow,
type SortableWeekItem,
} from "@/features/admin/course-plans/sortable-week-row";
/**
* 默认周次骨架(@contract-pending
*
* schema 无 weeklySchedule 字段,编辑页提供本地可排序的周次骨架,
* 让管理员预览进度排布;后端补齐契约后改为从详情查询初始化。
*/
function createDefaultWeekItems(): SortableWeekItem[] {
return [
{ week: 1, topic: "集合与函数概念", hours: 4, notes: "" },
{ week: 2, topic: "函数的基本性质", hours: 4, notes: "" },
{ week: 3, topic: "指数函数与对数函数", hours: 6, notes: "" },
{ week: 4, topic: "幂函数", hours: 3, notes: "" },
{ week: 5, topic: "函数应用", hours: 3, notes: "" },
];
}
/**
* 编辑表单客户端主体。需由 server page 包裹在 <Suspense> 中。
@@ -70,9 +99,21 @@ export function CoursePlanEditClient(): React.ReactElement {
const [teacherId, setTeacherId] = useState("");
const [academicYearId, setAcademicYearId] = useState("");
const [status, setStatus] = useState<string>("DRAFT");
const [semester, setSemester] = useState("");
const [syllabus, setSyllabus] = useState("");
const [objectives, setObjectives] = useState("");
const [totalHours, setTotalHours] = useState("");
const [weeklyHours, setWeeklyHours] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
// @contract-pending周次教学进度本地 stateschema 无 weeklySchedule 字段)
const [weekItems, setWeekItems] = useState<SortableWeekItem[]>(
createDefaultWeekItems,
);
// 首次拿到数据时初始化表单
// 注意AdminCoursePlan 不含 gradeId 字段gradeId 由用户在表单中重新选择(@contract-pending
useEffect(() => {
@@ -84,10 +125,51 @@ export function CoursePlanEditClient(): React.ReactElement {
setTeacherId(data.teacherId ?? "");
setAcademicYearId(data.academicYearId ?? "");
setStatus(data.status ?? "DRAFT");
setSemester(data.semester ?? "");
setSyllabus(data.syllabus ?? "");
setObjectives(data.objectives ?? "");
setTotalHours(
typeof data.totalHours === "number" ? String(data.totalHours) : "",
);
setWeeklyHours(
typeof data.weeklyHours === "number" ? String(data.weeklyHours) : "",
);
setStartDate(data.startDate ?? "");
setEndDate(data.endDate ?? "");
setInitialized(true);
}
}, [data, initialized]);
const handleMoveUp = (item: SortableWeekItem): void => {
setWeekItems((prev) => {
const idx = prev.findIndex((w) => w.week === item.week);
if (idx <= 0) return prev;
const next = [...prev];
const a = next[idx - 1];
const b = next[idx];
if (a && b) {
next[idx - 1] = b;
next[idx] = a;
}
return next;
});
};
const handleMoveDown = (item: SortableWeekItem): void => {
setWeekItems((prev) => {
const idx = prev.findIndex((w) => w.week === item.week);
if (idx < 0 || idx >= prev.length - 1) return prev;
const next = [...prev];
const a = next[idx];
const b = next[idx + 1];
if (a && b) {
next[idx] = b;
next[idx + 1] = a;
}
return next;
});
};
const handleFormSubmit = async (): Promise<void> => {
setFormError(null);
@@ -109,7 +191,8 @@ export function CoursePlanEditClient(): React.ReactElement {
}
// @contract-pendingUpdateCoursePlanInput 仅支持 id/name/description/objectives/status
// gradeId / classId / subjectId / teacherId / academicYearId 暂无对应字段,由后端补齐后扩展输入类型
// gradeId / classId / subjectId / teacherId / academicYearId / weekItems 暂无对应字段,
// 由后端补齐后扩展输入类型;周次排序仅在前端预览,保存时暂不同步
const input: UpdateCoursePlanInput = {
id: planId,
name: title.trim(),
@@ -218,42 +301,40 @@ export function CoursePlanEditClient(): React.ReactElement {
<div className="grid grid-cols-2 gap-4">
{/* 年级 */}
<FormField label={t("fieldGradeId")} required>
<select
<Select
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
onValueChange={setGradeId}
options={[
{
value: "",
label: gradesLoading ? tCommon("loading") : t("fieldGradeId"),
},
...(grades ?? []).map((g) => ({
value: g.id,
label: g.name,
})),
]}
disabled={gradesLoading}
>
<option value="">
{gradesLoading ? tCommon("loading") : t("fieldGradeId")}
</option>
{(grades ?? []).map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
/>
</FormField>
{/* 班级 */}
<FormField label={t("fieldClassId")} required>
<select
<Select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
onValueChange={setClassId}
options={[
{
value: "",
label: classesLoading ? tCommon("loading") : t("fieldClassId"),
},
...(classes ?? []).map((c) => ({
value: c.id,
label: c.name,
})),
]}
disabled={classesLoading}
>
<option value="">
{classesLoading ? tCommon("loading") : t("fieldClassId")}
</option>
{(classes ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
/>
</FormField>
</div>
@@ -272,59 +353,187 @@ export function CoursePlanEditClient(): React.ReactElement {
{/* 教师 */}
<FormField label={t("fieldTeacherId")}>
<select
<Select
value={teacherId}
onChange={(e) => setTeacherId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
onValueChange={setTeacherId}
options={[
{
value: "",
label: teachersLoading
? tCommon("loading")
: t("fieldTeacherId"),
},
...(teachers ?? []).map((teacher) => ({
value: teacher.id,
label: teacher.name,
})),
]}
disabled={teachersLoading}
>
<option value="">
{teachersLoading ? tCommon("loading") : t("fieldTeacherId")}
</option>
{(teachers ?? []).map((teacher) => (
<option key={teacher.id} value={teacher.id}>
{teacher.name}
</option>
))}
</select>
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 学年 */}
<FormField label={t("fieldAcademicYearId")}>
<select
<Select
value={academicYearId}
onChange={(e) => setAcademicYearId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
onValueChange={setAcademicYearId}
options={[
{
value: "",
label: yearsLoading
? tCommon("loading")
: t("fieldAcademicYearId"),
},
...(academicYears ?? []).map((y) => ({
value: y.id,
label: y.name,
})),
]}
disabled={yearsLoading}
>
<option value="">
{yearsLoading ? tCommon("loading") : t("fieldAcademicYearId")}
</option>
{(academicYears ?? []).map((y) => (
<option key={y.id} value={y.id}>
{y.name}
</option>
))}
</select>
/>
</FormField>
{/* 状态 */}
<FormField label={t("fieldStatus")}>
<select
<Select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{ADMIN_COURSE_PLAN_STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{formatAdminCoursePlanStatus(s)}
</option>
))}
</select>
onValueChange={setStatus}
options={ADMIN_COURSE_PLAN_STATUS_OPTIONS.map((s) => ({
value: s,
label: formatAdminCoursePlanStatus(s),
}))}
/>
</FormField>
</div>
{/* 学期 */}
<FormField label={t("fieldSemester")}>
<input
type="text"
value={semester}
onChange={(e) => setSemester(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="2026-fall"
/>
</FormField>
<div className="grid grid-cols-2 gap-4">
{/* 总课时 */}
<FormField label={t("fieldTotalHours")}>
<input
type="number"
min={0}
value={totalHours}
onChange={(e) => setTotalHours(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="120"
/>
</FormField>
{/* 周课时 */}
<FormField label={t("fieldWeeklyHours")}>
<input
type="number"
min={0}
value={weeklyHours}
onChange={(e) => setWeeklyHours(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="6"
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 开始日期 */}
<FormField label={t("fieldStartDate")}>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
{/* 结束日期 */}
<FormField label={t("fieldEndDate")}>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
</div>
{/* 教学大纲 */}
<FormField label={t("fieldSyllabus")}>
<textarea
value={syllabus}
onChange={(e) => setSyllabus(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("fieldSyllabus")}
/>
</FormField>
{/* 教学目标 */}
<FormField label={t("fieldObjectives")}>
<textarea
value={objectives}
onChange={(e) => setObjectives(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("fieldObjectives")}
/>
</FormField>
{/* 教学进度(@contract-pending周次排序为本地 state保存时暂不同步到后端 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{t("sectionSchedule")}</label>
<span className="text-xs text-muted-foreground">
{t("scheduleNotice")}
</span>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">{t("colWeek")}</TableHead>
<TableHead>{t("colTopic")}</TableHead>
<TableHead className="w-20">{t("colHours")}</TableHead>
<TableHead className="w-32">{t("colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{weekItems.length === 0 ? (
<TableRow>
<td
colSpan={4}
className="p-6 text-center text-sm text-muted-foreground"
>
{t("scheduleEmpty")}
</td>
</TableRow>
) : (
weekItems.map((item, idx) => (
<SortableWeekRow
key={item.week}
item={item}
canSort
isFirst={idx === 0}
isLast={idx === weekItems.length - 1}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))
)}
</TableBody>
</Table>
</div>
</div>
</FormPageShell>
);
}

View File

@@ -0,0 +1,286 @@
"use client";
/**
* 周计划项编辑对话框ARCHITECTURE.md §7.3 / §9.4 / §10 P5
*
* 基于 CICD src/modules/course-plans/components/course-plan-item-editor.tsx 适配到 portal-shell
* - Server Actions → Apollo Client mutations + MSW 兜底
* - 表单字段week / topic / content / hours / textbookChapter / completedAt / notes
* - 支持 create / edit 双模式
*
* 数据契约:
* - createCoursePlanItem / updateCoursePlanItem / deleteCoursePlanItem /
* toggleCoursePlanItemCompleted❌ schema 无 → MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Check, Trash2, X } from "lucide-react";
import {
useCreateCoursePlanItem,
useDeleteCoursePlanItem,
useToggleCoursePlanItemCompleted,
useUpdateCoursePlanItem,
type AdminCoursePlanItem,
type AdminCoursePlanItemInput,
type AdminCoursePlanItemUpdateInput,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { notify } from "@/shared/lib/notify";
export interface CoursePlanItemEditorProps {
planId: string;
item?: AdminCoursePlanItem;
mode: "create" | "edit";
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function CoursePlanItemEditor({
planId,
item,
mode,
open,
onOpenChange,
onSuccess,
}: CoursePlanItemEditorProps): React.ReactElement {
const t = useTranslations("admin.coursePlans.itemEditor");
const tCommon = useTranslations("common");
const [week, setWeek] = useState<number>(1);
const [topic, setTopic] = useState<string>("");
const [content, setContent] = useState<string>("");
const [hours, setHours] = useState<number>(2);
const [textbookChapter, setTextbookChapter] = useState<string>("");
const [completedAt, setCompletedAt] = useState<string>("");
const [notes, setNotes] = useState<string>("");
const { run: createItem, loading: creating } = useCreateCoursePlanItem();
const { run: updateItem, loading: updating } = useUpdateCoursePlanItem();
const { run: deleteItem, loading: deleting } = useDeleteCoursePlanItem();
const { run: toggleItem, loading: toggling } =
useToggleCoursePlanItemCompleted();
const isWorking = creating || updating || deleting || toggling;
useEffect(() => {
if (open) {
setWeek(item?.week ?? 1);
setTopic(item?.topic ?? "");
setContent(item?.content ?? "");
setHours(item?.hours ?? 2);
setTextbookChapter(item?.textbookChapter ?? "");
setCompletedAt(item?.completedAt ?? "");
setNotes(item?.notes ?? "");
}
}, [open, item]);
const handleSubmit = async (): Promise<void> => {
if (!topic.trim()) {
notify.error(t("errorTopicRequired"));
return;
}
try {
if (mode === "create") {
const input: AdminCoursePlanItemInput = {
planId,
week,
topic: topic.trim(),
content: content.trim() || null,
hours,
textbookChapter: textbookChapter.trim() || null,
notes: notes.trim() || null,
completedAt: completedAt || null,
};
await createItem(input);
notify.success(t("createSuccess"));
} else if (item) {
const input: AdminCoursePlanItemUpdateInput = {
week,
topic: topic.trim(),
content: content.trim() || null,
hours,
textbookChapter: textbookChapter.trim() || null,
notes: notes.trim() || null,
completedAt: completedAt || null,
};
await updateItem(item.id, input);
notify.success(t("updateSuccess"));
}
onSuccess?.();
onOpenChange(false);
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleDelete = async (): Promise<void> => {
if (!item) return;
try {
await deleteItem(item.id);
notify.success(t("deleteSuccess"));
onSuccess?.();
onOpenChange(false);
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleToggleComplete = async (): Promise<void> => {
if (!item) return;
try {
await toggleItem(item.id, !item.isCompleted);
notify.success(t("toggleSuccess"));
onSuccess?.();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === "create" ? t("createTitle") : t("editTitle")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="cpi-week">{t("week")}</Label>
<Input
id="cpi-week"
type="number"
min={1}
value={week}
onChange={(e) => setWeek(Number(e.target.value) || 1)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cpi-hours">{t("hours")}</Label>
<Input
id="cpi-hours"
type="number"
min={1}
value={hours}
onChange={(e) => setHours(Number(e.target.value) || 1)}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="cpi-topic">{t("topic")}</Label>
<Input
id="cpi-topic"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder={t("topicPlaceholder")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cpi-content">{t("content")}</Label>
<Textarea
id="cpi-content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("contentPlaceholder")}
className="min-h-[100px]"
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="cpi-chapter">{t("chapter")}</Label>
<Input
id="cpi-chapter"
value={textbookChapter}
onChange={(e) => setTextbookChapter(e.target.value)}
placeholder={t("chapterPlaceholder")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cpi-completedAt">{t("completedAt")}</Label>
<Input
id="cpi-completedAt"
type="date"
value={completedAt ? completedAt.slice(0, 10) : ""}
onChange={(e) => setCompletedAt(e.target.value)}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="cpi-notes">{t("notes")}</Label>
<Textarea
id="cpi-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("notesPlaceholder")}
/>
</div>
</div>
<DialogFooter className="gap-2">
{mode === "edit" && item ? (
<>
<Button
type="button"
variant="outline"
onClick={handleToggleComplete}
disabled={isWorking}
>
{item.isCompleted ? (
<>
<X className="mr-2 size-4" aria-hidden="true" />
{t("markIncomplete")}
</>
) : (
<>
<Check className="mr-2 size-4" aria-hidden="true" />
{t("markComplete")}
</>
)}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isWorking}
>
<Trash2 className="mr-2 size-4" aria-hidden="true" />
{t("delete")}
</Button>
</>
) : null}
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isWorking}
>
{t("cancel")}
</Button>
<Button type="button" onClick={handleSubmit} disabled={isWorking}>
{isWorking ? t("saving") : t("save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -17,7 +17,7 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import { ClipboardList, Download } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react";
@@ -25,8 +25,15 @@ import { useTranslations } from "next-intl";
import { useAdminCoursePlans, type AdminCoursePlanListItem } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { notify } from "@/shared/lib/notify";
import {
exportPlansToCsv,
type CoursePlanColumnLabels,
} from "@/features/admin/course-plans/export-utils";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Progress } from "@/shared/components/ui/progress";
import { Select } from "@/shared/components/ui/select";
import {
ListPageShell,
ListPageSkeleton,
@@ -46,6 +53,7 @@ import {
export function CoursePlansListClient(): React.ReactElement {
const t = useTranslations("admin.coursePlans.list");
const tCommon = useTranslations("common");
const tExport = useTranslations("admin.coursePlans.export");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
@@ -74,6 +82,33 @@ export function CoursePlansListClient(): React.ReactElement {
});
}, [data, search]);
const handleExportCsv = (): void => {
const items = filteredItems;
if (items.length === 0) {
notify.error(tExport("errorEmpty"));
return;
}
try {
const labels: CoursePlanColumnLabels = {
name: tExport("colName"),
className: tExport("colClass"),
subjectName: tExport("colSubject"),
teacherName: tExport("colTeacher"),
academicYearName: tExport("colAcademicYear"),
status: tExport("colStatus"),
createdAt: tExport("colCreatedAt"),
statusDraft: tExport("statusDraft"),
statusPublished: tExport("statusPublished"),
statusArchived: tExport("statusArchived"),
};
const filename = `${tExport("filename")}-${new Date().toISOString().slice(0, 10)}`;
exportPlansToCsv(filename, items, labels);
notify.success(tExport("success"));
} catch (err) {
notify.error(tExport("error", { message: String(err) }));
}
};
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
@@ -117,11 +152,22 @@ export function CoursePlansListClient(): React.ReactElement {
description={t("description")}
icon={<ClipboardList className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/admin/course-plans/create">
{t("createButton")}
</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleExportCsv}
disabled={filteredItems.length === 0}
>
<Download className="size-4" aria-hidden="true" />
{tExport("button")}
</Button>
<Button asChild>
<Link href="/shell/admin/course-plans/create">
{t("createButton")}
</Link>
</Button>
</div>
}
filters={
<>
@@ -132,19 +178,19 @@ export function CoursePlansListClient(): React.ReactElement {
/>
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("statusFilter")}</span>
<select
<Select
value={status}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
onValueChange={(v) => updateQuery("status", v)}
options={[
{ value: "", label: t("allStatuses") },
...ADMIN_COURSE_PLAN_STATUS_OPTIONS.map((s) => ({
value: s,
label: formatAdminCoursePlanStatus(s),
})),
]}
className="h-9 w-40"
aria-label={t("statusFilter")}
>
<option value="">{t("allStatuses")}</option>
{ADMIN_COURSE_PLAN_STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{formatAdminCoursePlanStatus(s)}
</option>
))}
</select>
/>
</label>
</>
}
@@ -186,6 +232,8 @@ function CoursePlansTable({
<th className="p-3 text-left font-medium">{t("colClass")}</th>
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
<th className="p-3 text-left font-medium">{t("colTeacher")}</th>
<th className="p-3 text-left font-medium">{t("colSemester")}</th>
<th className="p-3 text-left font-medium">{t("colProgress")}</th>
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
<th className="p-3 text-left font-medium">{t("colUpdatedAt")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
@@ -211,6 +259,12 @@ function CoursePlansTable({
<td className="p-3 text-muted-foreground">
{plan.teacherName || "-"}
</td>
<td className="p-3 text-muted-foreground">
{plan.semester || "-"}
</td>
<td className="p-3">
<PlanProgress plan={plan} />
</td>
<td className="p-3">
<StatusBadge status={plan.status} />
</td>
@@ -243,6 +297,32 @@ function CoursePlansTable({
);
}
/**
* 课程计划进度展示(进度条 + 课时文案)。
*/
function PlanProgress({
plan,
}: {
plan: AdminCoursePlanListItem;
}): React.ReactElement {
const t = useTranslations("admin.coursePlans.list");
const total = typeof plan.totalHours === "number" ? plan.totalHours : 0;
const completed =
typeof plan.completedHours === "number" ? plan.completedHours : 0;
if (total <= 0) {
return <span className="text-xs text-muted-foreground">-</span>;
}
const percent = Math.min(Math.round((completed / total) * 100), 100);
return (
<div className="flex flex-col gap-1">
<Progress value={percent} className="h-1.5" />
<span className="font-mono text-[10px] text-muted-foreground">
{t("progressHours", { completed, total })}
</span>
</div>
);
}
/**
* 课程计划状态徽章(按状态色阶展示)。
*/

View File

@@ -0,0 +1,141 @@
/**
* Admin Course Plans 导出工具ARCHITECTURE.md §9.4 / §10 P5
*
* 前端构造 CSV 并触发下载(@contract-pending无后端导出契约时使用
* 后端补齐导出契约后,可改为调用后端接口获取 CSV。
*
* 设计原则:
* - `planToExportRows` 为纯函数,便于单测;不直接依赖 i18n
* 状态文本由调用方通过 columnLabels 传入
* - `exportPlansToCsv` 执行客户端下载,调用方处理 notify
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import type { AdminCoursePlanListItem } from "@/lib/api";
import { downloadBlob } from "@/shared/lib/download";
/** 导出列标识(与 ExportRow 的 key 对应) */
export type CoursePlanExportColumnKey =
| "name"
| "className"
| "subjectName"
| "teacherName"
| "academicYearName"
| "status"
| "createdAt";
/** 列标签映射(由调用方传入已本地化的字符串) */
export interface CoursePlanColumnLabels {
name: string;
className: string;
subjectName: string;
teacherName: string;
academicYearName: string;
status: string;
createdAt: string;
/** 状态文本:草稿 */
statusDraft: string;
/** 状态文本:已发布 */
statusPublished: string;
/** 状态文本:已归档 */
statusArchived: string;
}
/** 导出列定义 */
export interface ExportColumn {
key: CoursePlanExportColumnKey;
label: string;
}
/** 导出行(键值对,键对应 ExportColumn.key */
export type ExportRow = Record<CoursePlanExportColumnKey, string>;
/**
* 将管理端课程计划状态枚举映射为本地化文本。
* 未知状态回退为原始字符串。
*/
function formatStatus(status: string, labels: CoursePlanColumnLabels): string {
switch (status) {
case "DRAFT":
return labels.statusDraft;
case "PUBLISHED":
return labels.statusPublished;
case "ARCHIVED":
return labels.statusArchived;
default:
return status;
}
}
/**
* 构建导出列配置(按固定顺序)。
*/
export function buildExportColumns(
labels: CoursePlanColumnLabels,
): readonly ExportColumn[] {
return [
{ key: "name", label: labels.name },
{ key: "className", label: labels.className },
{ key: "subjectName", label: labels.subjectName },
{ key: "teacherName", label: labels.teacherName },
{ key: "academicYearName", label: labels.academicYearName },
{ key: "status", label: labels.status },
{ key: "createdAt", label: labels.createdAt },
];
}
/**
* 将课程计划列表项转换为导出行(纯函数)。
*
* @param plan 管理端课程计划列表项
* @param labels 列标签 + 状态文本
*/
export function planToExportRows(
plan: AdminCoursePlanListItem,
labels: CoursePlanColumnLabels,
): ExportRow {
return {
name: plan.name ?? "",
className: plan.className ?? "",
subjectName: plan.subjectName ?? "",
teacherName: plan.teacherName ?? "",
academicYearName: plan.academicYearName ?? "",
status: formatStatus(plan.status, labels),
createdAt: plan.createdAt ?? "",
};
}
/**
* 转义 CSV 字段:包含逗号 / 引号 / 换行时用双引号包裹,
* 内部双引号转义为两个连续双引号。
*/
function escapeCsvField(value: string): string {
if (value === "") return "";
const needsQuote = /[",\n\r]/.test(value);
const escaped = value.replace(/"/g, '""');
return needsQuote ? `"${escaped}"` : escaped;
}
/**
* 客户端导出课程计划列表为 CSV 并触发下载。
*
* @param filename 文件名(不含扩展名)
* @param plans 课程计划列表
* @param labels 列标签 + 状态文本
*/
export function exportPlansToCsv(
filename: string,
plans: AdminCoursePlanListItem[],
labels: CoursePlanColumnLabels,
): void {
const columns = buildExportColumns(labels);
const header = columns.map((c) => escapeCsvField(c.label)).join(",");
const rows = plans.map((plan) => {
const row = planToExportRows(plan, labels);
return columns.map((c) => escapeCsvField(row[c.key])).join(",");
});
// 加 BOM 让 Excel 正确识别 UTF-8
const csv = "\uFEFF" + [header, ...rows].join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
downloadBlob(blob, `${filename}.csv`);
}

View File

@@ -0,0 +1,116 @@
"use client";
/**
* 可排序的周次表格行ARCHITECTURE.md §7.3 / §9.4 / §10 P5
*
* 设计:
* - 用上移 / 下移按钮替代拖拽,更稳定且对键盘 / 屏幕阅读器友好
* - 接收 week 数据 + onMoveUp / onMoveDown 回调,由调用方维护排序状态
* - 行数据形状由调用方决定SortableWeekItem组件不耦合具体字段
*
* 可访问性:
* - 上移 / 下移按钮带 aria-label
* - 禁用状态在首行 / 末行
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import type { JSX } from "react";
import { useTranslations } from "next-intl";
import { ArrowDown, ArrowUp } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { TableCell, TableRow } from "@/shared/components/ui/table";
import { cn } from "@/shared/lib/utils";
/**
* 可排序的周次项数据形状(由调用方决定具体字段,至少包含 week 序号)。
*/
export interface SortableWeekItem {
/** 周次序号1-based */
week: number;
/** 主题(用于行展示) */
topic: string;
/** 课时数(用于行展示) */
hours?: number;
/** 备注(用于行展示) */
notes?: string;
}
interface SortableWeekRowProps {
/** 当前周次数据 */
item: SortableWeekItem;
/** 是否可排序false 时隐藏上移 / 下移按钮) */
canSort: boolean;
/** 是否首行(禁用上移按钮) */
isFirst: boolean;
/** 是否末行(禁用下移按钮) */
isLast: boolean;
/** 上移回调 */
onMoveUp: (item: SortableWeekItem) => void;
/** 下移回调 */
onMoveDown: (item: SortableWeekItem) => void;
}
/**
* 可排序的周次表格行。
*
* 不实现 HTML5 drag-and-drop容易出 bug 且对键盘不友好),
* 改用上移 / 下移按钮控制顺序,调用方维护列表状态。
*/
export function SortableWeekRow({
item,
canSort,
isFirst,
isLast,
onMoveUp,
onMoveDown,
}: SortableWeekRowProps): JSX.Element {
const t = useTranslations("admin.coursePlans.sortableWeekRow");
return (
<TableRow className={cn("hover:bg-muted/30")}>
<TableCell className="w-16 font-medium">{item.week}</TableCell>
<TableCell>
<div className="space-y-1">
<p className="font-medium">{item.topic}</p>
{item.notes ? (
<p className="text-xs text-muted-foreground italic">
{t("notes", { notes: item.notes })}
</p>
) : null}
</div>
</TableCell>
<TableCell className="w-20 text-muted-foreground">
{typeof item.hours === "number" ? item.hours : "—"}
</TableCell>
{canSort ? (
<TableCell className="w-32">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onMoveUp(item)}
disabled={isFirst}
aria-label={t("moveUpAria", { week: item.week })}
>
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onMoveDown(item)}
disabled={isLast}
aria-label={t("moveDownAria", { week: item.week })}
>
<ArrowDown className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
</TableCell>
) : null}
</TableRow>
);
}

View File

@@ -0,0 +1,197 @@
"use client";
/**
* 课程计划模板选择对话框ARCHITECTURE.md §7.3 / §9.4 / §10 P5
*
* 数据契约:
* - 查询 coursePlanTemplates(subjectId, gradeLevel):❌ schema 无 → MSW 兜底(@contract-pending
* - 选择模板后调用 onSelect 回调,由调用方填充表单字段
*
* 设计:
* - 列表加载通过 useCoursePlanTemplates hook@/lib/api
* - 搜索为客户端过滤(数据量可控时性能足够)
* - 选择后不直接克隆,仅触发 onSelect(template),由调用方决定如何使用
*
* 可访问性:
* - 对话框带 role="dialog"(由 Radix Dialog 提供)
* - 列表项带 aria-label 描述
* - 加载状态显示 aria-live="polite"
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import type { JSX } from "react";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { FileText, Loader2, Search } from "lucide-react";
import { useCoursePlanTemplates, type CoursePlanTemplate } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
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 { cn } from "@/shared/lib/utils";
interface TemplatePickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 可选学科过滤 */
subjectId?: string;
/** 可选年级过滤 */
gradeLevel?: string;
/** 选中模板后的回调(由调用方决定如何使用模板数据) */
onSelect: (template: CoursePlanTemplate) => void;
}
/**
* 课程计划模板选择对话框。
*
* 列出 MSW 兜底的模板候选,支持客户端搜索过滤;
* 选中后调用 onSelect 回调,由调用方填充表单字段。
*/
export function TemplatePickerDialog({
open,
onOpenChange,
subjectId,
gradeLevel,
onSelect,
}: TemplatePickerDialogProps): JSX.Element {
const t = useTranslations("admin.coursePlans.templates");
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | undefined>();
// @contract-pendingMSW 兜底
const { data, loading } = useCoursePlanTemplates(
{ subjectId, gradeLevel },
{ enabled: open },
);
const filtered = (data ?? []).filter((tpl) => {
if (!query.trim()) return true;
const q = query.toLowerCase();
return (
tpl.name.toLowerCase().includes(q) ||
tpl.description.toLowerCase().includes(q) ||
tpl.subject.toLowerCase().includes(q) ||
tpl.gradeLevel.toLowerCase().includes(q)
);
});
const handleConfirm = (): void => {
const selected = (data ?? []).find((tpl) => tpl.id === selectedId);
if (!selected) {
notify.error(t("errorNoSelection"));
return;
}
try {
onSelect(selected);
onOpenChange(false);
setSelectedId(undefined);
setQuery("");
} catch {
notify.error(t("errorApply"));
}
};
const handleOpenChange = (next: boolean): void => {
if (!next) {
setSelectedId(undefined);
setQuery("");
}
onOpenChange(next);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[80vh] max-w-2xl">
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<div className="relative">
<Search
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("searchPlaceholder")}
className="pl-9"
aria-label={t("searchPlaceholder")}
/>
</div>
<div className="h-80 overflow-y-auto rounded-md border">
{loading ? (
<div
className="flex h-full items-center justify-center gap-2 p-8 text-sm text-muted-foreground"
aria-live="polite"
>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{t("loading")}
</div>
) : filtered.length === 0 ? (
<div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
{t("empty")}
</div>
) : (
<ul className="divide-y" role="listbox">
{filtered.map((tpl) => (
<li
key={tpl.id}
role="option"
aria-selected={selectedId === tpl.id}
>
<button
type="button"
onClick={() => setSelectedId(tpl.id)}
className={cn(
"flex w-full items-start gap-3 p-3 text-left transition-colors hover:bg-muted/50",
selectedId === tpl.id && "bg-accent",
)}
>
<FileText
className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div className="flex-1 space-y-1">
<p className="font-medium">{tpl.name}</p>
<p className="line-clamp-2 text-xs text-muted-foreground">
{tpl.description}
</p>
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<Badge variant="outline">{tpl.subject}</Badge>
<Badge variant="outline">{tpl.gradeLevel}</Badge>
</div>
</div>
</button>
</li>
))}
</ul>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={loading}
>
{t("cancel")}
</Button>
<Button onClick={handleConfirm} disabled={!selectedId || loading}>
{t("confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest";
import type { StandardsCoverageCell } from "@/lib/api/admin-p5";
import {
COVERAGE_CRITICAL_THRESHOLD,
COVERAGE_HIGH_THRESHOLD,
COVERAGE_LOW_THRESHOLD,
COVERAGE_MEDIUM_THRESHOLD,
@@ -19,7 +20,9 @@ import {
formatCoverageRate,
formatLessonPlanCount,
safeCoverageRate,
safeLinked,
safeLessonPlanCount,
safeTotal,
} from "../transformations";
const sampleCells: StandardsCoverageCell[] = [
@@ -30,6 +33,8 @@ const sampleCells: StandardsCoverageCell[] = [
gradeName: "高三",
coverageRate: 0.85,
lessonPlanCount: 8,
total: 10,
linked: 8,
},
{
standardId: "std-002",
@@ -38,6 +43,8 @@ const sampleCells: StandardsCoverageCell[] = [
gradeName: "高三",
coverageRate: 0.4,
lessonPlanCount: 4,
total: 10,
linked: 4,
},
{
standardId: "std-001",
@@ -46,6 +53,8 @@ const sampleCells: StandardsCoverageCell[] = [
gradeName: "高二",
coverageRate: 0.1,
lessonPlanCount: 1,
total: 10,
linked: 1,
},
];
@@ -66,9 +75,13 @@ describe("coverageRateToIntensity", () => {
expect(coverageRateToIntensity(0.49)).toBe("low");
});
it("returns 'none' for rate < 0.2 (including 0)", () => {
it("returns 'critical' for 0 < rate < 0.2", () => {
expect(coverageRateToIntensity(0.01)).toBe("critical");
expect(coverageRateToIntensity(0.19)).toBe("critical");
});
it("returns 'none' only for rate === 0", () => {
expect(coverageRateToIntensity(0)).toBe("none");
expect(coverageRateToIntensity(0.19)).toBe("none");
});
it("returns 'none' for invalid input (NaN/negative/>1)", () => {
@@ -82,24 +95,31 @@ describe("coverageRateToIntensity", () => {
expect(COVERAGE_HIGH_THRESHOLD).toBe(0.8);
expect(COVERAGE_MEDIUM_THRESHOLD).toBe(0.5);
expect(COVERAGE_LOW_THRESHOLD).toBe(0.2);
expect(COVERAGE_CRITICAL_THRESHOLD).toBe(0.2);
});
});
describe("coverageIntensityToCellClass", () => {
it("returns primary/80 class for high", () => {
it("returns emerald class for high", () => {
const cls = coverageIntensityToCellClass("high");
expect(cls).toContain("bg-primary/80");
expect(cls).toContain("text-primary-foreground");
expect(cls).toContain("bg-emerald-500/80");
expect(cls).toContain("text-white");
});
it("returns primary/40 class for medium", () => {
it("returns blue class for medium", () => {
const cls = coverageIntensityToCellClass("medium");
expect(cls).toContain("bg-primary/40");
expect(cls).toContain("bg-blue-500/60");
});
it("returns primary/15 class for low", () => {
it("returns amber class for low", () => {
const cls = coverageIntensityToCellClass("low");
expect(cls).toContain("bg-primary/15");
expect(cls).toContain("bg-amber-500/70");
});
it("returns red class for critical", () => {
const cls = coverageIntensityToCellClass("critical");
expect(cls).toContain("bg-red-500/70");
expect(cls).toContain("text-white");
});
it("returns muted class for none", () => {
@@ -120,6 +140,9 @@ describe("coverageRateToCellClass", () => {
expect(coverageRateToCellClass(0.3)).toBe(
coverageIntensityToCellClass("low"),
);
expect(coverageRateToCellClass(0.1)).toBe(
coverageIntensityToCellClass("critical"),
);
expect(coverageRateToCellClass(0)).toBe(
coverageIntensityToCellClass("none"),
);
@@ -242,6 +265,8 @@ describe("buildHeatmapMatrix", () => {
);
expect(cellStd001Grade12?.rate).toBe(0.85);
expect(cellStd001Grade12?.lessonPlanCount).toBe(8);
expect(cellStd001Grade12?.linked).toBe(8);
expect(cellStd001Grade12?.total).toBe(10);
});
it("fills missing combinations with rate=0", () => {
@@ -253,6 +278,8 @@ describe("buildHeatmapMatrix", () => {
);
expect(cellStd002Grade11?.rate).toBe(0);
expect(cellStd002Grade11?.lessonPlanCount).toBe(0);
expect(cellStd002Grade11?.linked).toBe(0);
expect(cellStd002Grade11?.total).toBe(0);
});
it("preserves first-seen order of standards and grades", () => {
@@ -272,6 +299,7 @@ describe("buildHeatmapMatrix", () => {
gradeName: "",
coverageRate: 0.5,
lessonPlanCount: 1,
total: 2,
},
];
const { grades } = buildHeatmapMatrix(cells);
@@ -279,6 +307,42 @@ describe("buildHeatmapMatrix", () => {
});
});
describe("safeLinked", () => {
it("returns linked when present", () => {
expect(safeLinked({ linked: 5, lessonPlanCount: 8 })).toBe(5);
});
it("falls back to lessonPlanCount when linked missing", () => {
expect(safeLinked({ lessonPlanCount: 8 })).toBe(8);
});
it("returns 0 when neither field present", () => {
expect(safeLinked({})).toBe(0);
});
it("falls back to lessonPlanCount when linked is non-finite", () => {
expect(safeLinked({ linked: Number.NaN, lessonPlanCount: 3 })).toBe(3);
});
});
describe("safeTotal", () => {
it("returns total when present", () => {
expect(safeTotal({ total: 10, lessonPlanCount: 8 })).toBe(10);
});
it("falls back to lessonPlanCount when total missing", () => {
expect(safeTotal({ lessonPlanCount: 8 })).toBe(8);
});
it("returns 0 when neither field present", () => {
expect(safeTotal({})).toBe(0);
});
it("falls back to lessonPlanCount when total is non-finite", () => {
expect(safeTotal({ total: Number.NaN, lessonPlanCount: 3 })).toBe(3);
});
});
describe("calcAverageCoverage", () => {
it("returns 0 for empty array", () => {
expect(calcAverageCoverage([])).toBe(0);
@@ -299,6 +363,7 @@ describe("calcAverageCoverage", () => {
gradeName: "高三",
coverageRate: 0.7,
lessonPlanCount: 1,
total: 2,
},
]),
).toBe(0.7);
@@ -313,6 +378,7 @@ describe("calcAverageCoverage", () => {
gradeName: "高三",
coverage: 0.6,
lessonPlanCount: 1,
total: 2,
} as unknown as StandardsCoverageCell,
];
expect(calcAverageCoverage(cells)).toBeCloseTo(0.6, 2);

View File

@@ -28,6 +28,7 @@ import {
} from "@/shared/components/page-templates";
import {
buildHeatmapMatrix,
coverageIntensityToCellClass,
coverageRateToCellClass,
formatCoverageRate,
formatLessonPlanCount,
@@ -142,6 +143,9 @@ function StatsCardsGrid({
/**
* 标准覆盖热图卡片(标准 × 年级 矩阵)。
*
* 单元格显示覆盖率 + linked/total 课案数;底部附 5 档颜色图例
* emerald/blue/amber/red/muted对齐 CICD 热图分级)。
*/
function HeatmapCard({
matrix,
@@ -163,7 +167,12 @@ function HeatmapCard({
return (
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">{t("list.heatmapTitle")}</h2>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">{t("list.heatmapTitle")}</h2>
<p className="text-xs text-muted-foreground">
{t("list.heatmapLinkedTotalHint")}
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
@@ -189,15 +198,20 @@ function HeatmapCard({
<td
key={`${cell.standardId}:${cell.gradeId}`}
className={`p-3 text-center ${cls}`}
title={`${cell.standardName} / ${cell.gradeName}: ${formatCoverageRate(
rate,
)} (${formatLessonPlanCount(cell.lessonPlanCount)})`}
title={t("list.heatmapCellTooltip", {
standard: cell.standardName,
grade: cell.gradeName,
rate: formatCoverageRate(rate),
linked: cell.linked,
total: cell.total,
})}
>
<div className="font-mono text-xs">
{formatCoverageRate(rate)}
</div>
<div className="mt-1 text-[10px] opacity-75">
{formatLessonPlanCount(cell.lessonPlanCount)}
<div className="mt-1 text-[10px] opacity-80">
{formatLessonPlanCount(cell.linked)}/
{formatLessonPlanCount(cell.total)}
</div>
</td>
);
@@ -207,7 +221,36 @@ function HeatmapCard({
</tbody>
</table>
</div>
<HeatmapLegend />
</CardContent>
</Card>
);
}
/**
* 热图颜色图例5 档emerald/blue/amber/red/muted对齐 CICD
*/
function HeatmapLegend(): React.ReactElement {
const t = useTranslations("admin.curriculumMap");
const items: Array<{ key: string; cls: string }> = [
{ key: "legendHigh", cls: coverageIntensityToCellClass("high") },
{ key: "legendMedium", cls: coverageIntensityToCellClass("medium") },
{ key: "legendLow", cls: coverageIntensityToCellClass("low") },
{ key: "legendCritical", cls: coverageIntensityToCellClass("critical") },
{ key: "legendNone", cls: coverageIntensityToCellClass("none") },
];
return (
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground">
<span className="font-medium">{t("list.legendTitle")}</span>
{items.map((item) => (
<div key={item.key} className="flex items-center gap-1.5">
<span
className={`inline-block size-3 rounded-sm ${item.cls}`}
aria-hidden="true"
/>
<span>{t(`list.${item.key}`)}</span>
</div>
))}
</div>
);
}

View File

@@ -6,20 +6,23 @@
*/
import type { StandardsCoverageCell } from "@/lib/api/admin-p5";
/** 覆盖率强度等级 */
export type CoverageIntensity = "high" | "medium" | "low" | "none";
/** 覆盖率强度等级5 档none/critical/low/medium/high对齐 CICD 热图分级) */
export type CoverageIntensity = "high" | "medium" | "low" | "critical" | "none";
/** 覆盖率百分比下限阈值(与 coverageRateToIntensity 对齐) */
export const COVERAGE_HIGH_THRESHOLD = 0.8;
export const COVERAGE_MEDIUM_THRESHOLD = 0.5;
export const COVERAGE_LOW_THRESHOLD = 0.2;
/** critical 档上界(与 low 档下界对齐,仅 0 视为 none */
export const COVERAGE_CRITICAL_THRESHOLD = 0.2;
/**
* 将覆盖率0-1映射为强度等级
* - >= 0.8 → high
* - >= 0.5 → medium
* - >= 0.2 → low
* - < 0.2(含 0 → none
* 将覆盖率0-1映射为 5 档强度等级(对齐 CICD red/amber/blue/emerald/muted
* - >= 0.8 → highemerald
* - >= 0.5 → mediumblue
* - >= 0.2 → lowamber
* - > 0 → criticalred
* - === 0 → nonemuted
* 输入无效NaN/负数/大于 1→ none
*/
export function coverageRateToIntensity(rate: number): CoverageIntensity {
@@ -29,26 +32,31 @@ export function coverageRateToIntensity(rate: number): CoverageIntensity {
if (rate >= COVERAGE_HIGH_THRESHOLD) return "high";
if (rate >= COVERAGE_MEDIUM_THRESHOLD) return "medium";
if (rate >= COVERAGE_LOW_THRESHOLD) return "low";
if (rate > 0) return "critical";
return "none";
}
/**
* 根据覆盖率返回 Tailwind 单元格背景类名(语义色阶,避免硬编码 #hex)。
* - high → bg-primary/80 text-primary-foreground
* - medium → bg-primary/40 text-foreground
* - low → bg-primary/15 text-foreground
* - none → bg-muted text-muted-foreground
* 根据强度返回 Tailwind 单元格背景类名(5 档语义色阶,对齐 CICD 热图)。
* 使用标准 Tailwind bg-* 类(非 #hex 字面量、非任意值)。
* - high → bg-emerald-500/80 text-white
* - medium → bg-blue-500/60 text-white
* - low → bg-amber-500/70 text-foreground
* - critical → bg-red-500/70 text-white
* - none → bg-muted text-muted-foreground
*/
export function coverageIntensityToCellClass(
intensity: CoverageIntensity,
): string {
switch (intensity) {
case "high":
return "bg-primary/80 text-primary-foreground";
return "bg-emerald-500/80 text-white";
case "medium":
return "bg-primary/40 text-foreground";
return "bg-blue-500/60 text-white";
case "low":
return "bg-primary/15 text-foreground";
return "bg-amber-500/70 text-foreground";
case "critical":
return "bg-red-500/70 text-white";
case "none":
default:
return "bg-muted text-muted-foreground";
@@ -124,6 +132,34 @@ export function safeLessonPlanCount(cell: {
return 0;
}
/**
* 安全读取 cell.linked已关联教案数对齐 CICD linked/total 口径)。
* linked 缺失时回退到 lessonPlanCount两者同义
*/
export function safeLinked(cell: {
linked?: number;
lessonPlanCount?: number;
}): number {
if (typeof cell.linked === "number" && Number.isFinite(cell.linked)) {
return cell.linked;
}
return safeLessonPlanCount(cell);
}
/**
* 安全读取 cell.total应覆盖总课时数对齐 CICD linked/total 口径)。
* total 缺失时回退到 lessonPlanCount避免 0/0 显示)。
*/
export function safeTotal(cell: {
total?: number;
lessonPlanCount?: number;
}): number {
if (typeof cell.total === "number" && Number.isFinite(cell.total)) {
return cell.total;
}
return safeLessonPlanCount(cell);
}
/** 热图矩阵行:标准 → 各年级的覆盖率 */
export interface HeatmapRow {
standardId: string;
@@ -131,7 +167,7 @@ export interface HeatmapRow {
cells: HeatmapCell[];
}
/** 热图单元格 */
/** 热图单元格(含 linked/total 课案数,对齐 CICD 口径) */
export interface HeatmapCell {
standardId: string;
standardName: string;
@@ -139,6 +175,10 @@ export interface HeatmapCell {
gradeName: string;
rate: number;
lessonPlanCount: number;
/** 已关联教案数linked对齐 CICD */
linked: number;
/** 应覆盖总课时数total对齐 CICD */
total: number;
}
/**
@@ -195,6 +235,8 @@ export function buildHeatmapMatrix(
gradeName: g.gradeName,
rate: cell ? safeCoverageRate(cell) : 0,
lessonPlanCount: cell ? safeLessonPlanCount(cell) : 0,
linked: cell ? safeLinked(cell) : 0,
total: cell ? safeTotal(cell) : 0,
};
}),
}));

View File

@@ -20,13 +20,23 @@ import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useGrades, useTeacherOptions } from "@/lib/api/admin-p5";
import {
useAdminCreateElective,
useGrades,
useTeacherOptions,
} from "@/lib/api/admin-p5";
import { FormPageShell } from "@/shared/components/page-templates";
import { Select } from "@/shared/components/ui/select";
import { notify } from "@/shared/lib/notify";
import { formatElectiveStatus } from "@/features/admin/elective/transformations";
import {
formatElectiveStatus,
formatElectiveSelectionMode,
} from "@/features/admin/elective/transformations";
/** 状态选项(与 admin.elective.list i18n 对齐) */
const STATUS_OPTIONS = ["DRAFT", "OPEN", "CLOSED", "FULL"] as const;
/** 选课模式选项(@contract-pendingFIRST_COME | LOTTERY */
const SELECTION_MODE_OPTIONS = ["FIRST_COME", "LOTTERY"] as const;
/**
* 新建表单客户端主体。需由 server page 包裹在 <Suspense> 中。
@@ -40,6 +50,7 @@ export function ElectiveCreateClient(): React.ReactElement {
// @contract-pendingMSW 兜底
const { data: grades, loading: gradesLoading } = useGrades();
const { data: teachers, loading: teachersLoading } = useTeacherOptions();
const { run: createElective, loading: creating } = useAdminCreateElective();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
@@ -47,10 +58,16 @@ export function ElectiveCreateClient(): React.ReactElement {
const [gradeId, setGradeId] = useState("");
const [teacherId, setTeacherId] = useState("");
const [capacity, setCapacity] = useState("");
const [credit, setCredit] = useState("");
const [classroom, setClassroom] = useState("");
const [schedule, setSchedule] = useState("");
const [selectionMode, setSelectionMode] = useState<string>("FIRST_COME");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [selectionStartAt, setSelectionStartAt] = useState("");
const [selectionEndAt, setSelectionEndAt] = useState("");
const [dropDeadline, setDropDeadline] = useState("");
const [status, setStatus] = useState<string>("DRAFT");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const handleSubmit = async (): Promise<void> => {
@@ -70,11 +87,29 @@ export function ElectiveCreateClient(): React.ReactElement {
return;
}
setSubmitting(true);
try {
// @contract-pendingcreateElective mutation 契约未补齐
// 当前通过 MSW 兜底模拟提交成功,后端补齐后切换为真实 mutation
await new Promise((resolve) => setTimeout(resolve, 300));
const creditNum = credit ? Number.parseInt(credit, 10) : undefined;
await createElective({
name: name.trim(),
description: description.trim() || undefined,
subjectId: subjectId.trim(),
gradeId,
teacherId: teacherId || undefined,
capacity: capacityNum,
credit:
creditNum !== undefined && Number.isFinite(creditNum)
? creditNum
: undefined,
classroom: classroom.trim() || undefined,
schedule: schedule.trim() || undefined,
selectionMode,
startDate: startDate || undefined,
endDate: endDate || undefined,
selectionStartAt: selectionStartAt || undefined,
selectionEndAt: selectionEndAt || undefined,
dropDeadline: dropDeadline || undefined,
status,
});
notify.success(t("success"));
startTransition(() => {
router.push("/shell/admin/elective");
@@ -82,8 +117,6 @@ export function ElectiveCreateClient(): React.ReactElement {
} catch (err) {
setFormError(`${t("error")}: ${String(err)}`);
notify.error(tCommon("error.loadFailed", { message: String(err) }));
} finally {
setSubmitting(false);
}
};
@@ -94,7 +127,7 @@ export function ElectiveCreateClient(): React.ReactElement {
icon={<BookOpen className="size-6" />}
backHref="/shell/admin/elective"
onSubmit={handleSubmit}
submitting={submitting}
submitting={creating}
submitLabel={t("submit")}
cancelLabel={t("cancel")}
errorSummary={
@@ -141,46 +174,50 @@ export function ElectiveCreateClient(): React.ReactElement {
{/* 年级 */}
<FormField label={t("fieldGradeId")}>
<select
<Select
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
onValueChange={setGradeId}
options={[
{
value: "",
label: gradesLoading ? tCommon("loading") : t("fieldGradeId"),
},
...(grades ?? []).map((g) => ({
value: g.id,
label: g.name,
})),
]}
disabled={gradesLoading}
>
<option value="">
{gradesLoading ? tCommon("loading") : t("fieldGradeId")}
</option>
{(grades ?? []).map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
className="h-9 w-full"
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 教师 */}
<FormField label={t("fieldTeacherId")}>
<select
<Select
value={teacherId}
onChange={(e) => setTeacherId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
onValueChange={setTeacherId}
options={[
{
value: "",
label: teachersLoading
? tCommon("loading")
: t("fieldTeacherId"),
},
...(teachers ?? []).map((teacher) => ({
value: teacher.id,
label: teacher.name,
})),
]}
disabled={teachersLoading}
>
<option value="">
{teachersLoading ? tCommon("loading") : t("fieldTeacherId")}
</option>
{(teachers ?? []).map((teacher) => (
<option key={teacher.id} value={teacher.id}>
{teacher.name}
</option>
))}
</select>
className="h-9 w-full"
/>
</FormField>
{/* 容量 */}
<FormField label={t("fieldCapacity")}>
<FormField label={t("fieldCapacity")} required>
<input
type="number"
min={1}
@@ -188,6 +225,59 @@ export function ElectiveCreateClient(): React.ReactElement {
onChange={(e) => setCapacity(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="30"
required
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 学分 */}
<FormField label={t("fieldCredit")}>
<input
type="number"
min={0}
step={0.5}
value={credit}
onChange={(e) => setCredit(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="2"
/>
</FormField>
{/* 选课模式 */}
<FormField label={t("fieldSelectionMode")}>
<Select
value={selectionMode}
onValueChange={setSelectionMode}
options={SELECTION_MODE_OPTIONS.map((m) => ({
value: m,
label: formatElectiveSelectionMode(m),
}))}
className="h-9 w-full"
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 教室 */}
<FormField label={t("fieldClassroom")}>
<input
type="text"
value={classroom}
onChange={(e) => setClassroom(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("fieldClassroom")}
/>
</FormField>
{/* 上课时间 */}
<FormField label={t("fieldSchedule")}>
<input
type="text"
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("fieldSchedule")}
/>
</FormField>
</div>
@@ -214,20 +304,52 @@ export function ElectiveCreateClient(): React.ReactElement {
</FormField>
</div>
{/* 状态 */}
<FormField label={t("fieldStatus")}>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{formatElectiveStatus(s)}
</option>
))}
</select>
</FormField>
<div className="grid grid-cols-2 gap-4">
{/* 选课开始时间 */}
<FormField label={t("fieldSelectionStartAt")}>
<input
type="datetime-local"
value={selectionStartAt}
onChange={(e) => setSelectionStartAt(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
{/* 选课结束时间 */}
<FormField label={t("fieldSelectionEndAt")}>
<input
type="datetime-local"
value={selectionEndAt}
onChange={(e) => setSelectionEndAt(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 退课截止时间 */}
<FormField label={t("fieldDropDeadline")}>
<input
type="datetime-local"
value={dropDeadline}
onChange={(e) => setDropDeadline(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
{/* 状态 */}
<FormField label={t("fieldStatus")}>
<Select
value={status}
onValueChange={setStatus}
options={STATUS_OPTIONS.map((s) => ({
value: s,
label: formatElectiveStatus(s),
}))}
className="h-9 w-full"
/>
</FormField>
</div>
{/* @contract-pending 提示 */}
<p className="text-xs text-muted-foreground">{t("contractPending")}</p>

View File

@@ -13,15 +13,23 @@
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
*/
import { BookOpen } from "lucide-react";
import { BookOpen, Trash2 } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useAdminElective } from "@/lib/api/admin-p5";
import {
useAdminElective,
useCloseElectiveSelection,
useDeleteElective,
useOpenElectiveSelection,
useRunElectiveLottery,
} from "@/lib/api/admin-p5";
import type { AdminElective } from "@/lib/api/admin-p5";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog";
import {
DetailPageShell,
DetailPageSkeleton,
@@ -33,6 +41,8 @@ import {
electiveStatusToBadgeClass,
enrollmentRateToColorClass,
formatElectiveDate,
formatElectiveDateOnly,
formatElectiveSelectionMode,
formatElectiveStatus,
formatEnrollmentCount,
getEnrolledCount,
@@ -41,6 +51,7 @@ import {
isElectiveEditable,
type FlexibleElectiveItem,
} from "@/features/admin/elective/transformations";
import { notify } from "@/shared/lib/notify";
/**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
@@ -48,11 +59,62 @@ import {
export function ElectiveDetailClient(): React.ReactElement {
const t = useTranslations("admin.elective.detail");
const tCommon = useTranslations("common");
const router = useRouter();
const params = useParams<{ id: string }>();
const electiveId = params?.id ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useAdminElective(electiveId);
const { data, loading, error, refetch } = useAdminElective(electiveId);
const { run: deleteElective, loading: deleting } = useDeleteElective();
const { run: openSelection, loading: opening } = useOpenElectiveSelection();
const { run: closeSelection, loading: closing } = useCloseElectiveSelection();
const { run: runLottery, loading: lotterying } = useRunElectiveLottery();
const [deleteOpen, setDeleteOpen] = useState(false);
const handleDelete = async (): Promise<void> => {
try {
await deleteElective(electiveId);
notify.success(t("deleteSuccess"));
setDeleteOpen(false);
router.push("/shell/admin/elective");
} catch (err) {
notify.error(t("deleteFailed"));
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleOpen = async (): Promise<void> => {
try {
await openSelection(electiveId);
notify.success(t("openSuccess"));
void refetch();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleClose = async (): Promise<void> => {
try {
await closeSelection(electiveId);
notify.success(t("closeSuccess"));
void refetch();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleLottery = async (): Promise<void> => {
try {
await runLottery(electiveId);
notify.success(t("lotterySuccess"));
void refetch();
} catch (err) {
notify.error(t("lotteryFailed"));
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const actionLoading = opening || closing || lotterying;
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
@@ -62,6 +124,8 @@ export function ElectiveDetailClient(): React.ReactElement {
</div>
) : undefined;
const status = data?.status ?? "";
return (
<DetailPageShell
title={data?.name ?? t("title")}
@@ -69,12 +133,52 @@ export function ElectiveDetailClient(): React.ReactElement {
icon={<BookOpen className="size-6" />}
backHref="/shell/admin/elective"
actions={
data && isElectiveEditable(data.status) ? (
<Button asChild variant="outline">
<Link href={`/shell/admin/elective/${data.id}/edit`}>
{t("edit")}
</Link>
</Button>
data ? (
<div className="flex items-center gap-2">
{isElectiveEditable(data.status) ? (
<Button asChild variant="outline">
<Link href={`/shell/admin/elective/${data.id}/edit`}>
{t("edit")}
</Link>
</Button>
) : null}
{status === "DRAFT" ? (
<Button
variant="outline"
onClick={() => void handleOpen()}
disabled={actionLoading}
>
{t("openSelection")}
</Button>
) : null}
{status === "OPEN" || status === "FULL" ? (
<Button
variant="outline"
onClick={() => void handleClose()}
disabled={actionLoading}
>
{t("closeSelection")}
</Button>
) : null}
{data.selectionMode === "LOTTERY" && status !== "DRAFT" ? (
<Button
variant="outline"
onClick={() => void handleLottery()}
disabled={actionLoading}
>
{t("runLottery")}
</Button>
) : null}
<Button
variant="outline"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setDeleteOpen(true)}
disabled={deleting}
>
<Trash2 className="mr-2 size-4" aria-hidden="true" />
{t("delete")}
</Button>
</div>
) : null
}
loading={loading}
@@ -89,6 +193,17 @@ export function ElectiveDetailClient(): React.ReactElement {
}
>
{data ? <ElectiveDetailBody elective={data} /> : null}
<ConfirmDeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={t("deleteTitle")}
description={t("deleteDescription")}
confirmText={t("deleteConfirm")}
cancelText={t("deleteCancel")}
onConfirm={handleDelete}
isWorking={deleting}
/>
</DetailPageShell>
);
}
@@ -132,6 +247,28 @@ function ElectiveDetailBody({
label={t("fieldStatus")}
value={<StatusBadge status={elective.status} />}
/>
<DetailField
label={t("fieldCredit")}
value={
typeof elective.credit === "number" ? String(elective.credit) : "-"
}
/>
<DetailField
label={t("fieldClassroom")}
value={elective.classroom || "-"}
/>
<DetailField
label={t("fieldSchedule")}
value={elective.schedule || "-"}
/>
<DetailField
label={t("fieldSelectionMode")}
value={
elective.selectionMode
? formatElectiveSelectionMode(elective.selectionMode)
: "-"
}
/>
</DetailSection>
<DetailSection title={t("sectionSchedule")}>
@@ -153,6 +290,26 @@ function ElectiveDetailBody({
</span>
}
/>
<DetailField
label={t("fieldStartDate")}
value={formatElectiveDateOnly(elective.startDate)}
/>
<DetailField
label={t("fieldEndDate")}
value={formatElectiveDateOnly(elective.endDate)}
/>
<DetailField
label={t("fieldSelectionStart")}
value={formatElectiveDate(elective.selectionStartAt)}
/>
<DetailField
label={t("fieldSelectionEnd")}
value={formatElectiveDate(elective.selectionEndAt)}
/>
<DetailField
label={t("fieldDropDeadline")}
value={formatElectiveDate(elective.dropDeadline)}
/>
</DetailSection>
<Card>
@@ -173,6 +330,9 @@ function ElectiveDetailBody({
<th className="p-3 text-left font-medium">
{t("enrollmentStudentNo")}
</th>
<th className="p-3 text-left font-medium">
{t("enrollmentPriority")}
</th>
<th className="p-3 text-left font-medium">
{t("enrollmentEnrolledAt")}
</th>
@@ -188,7 +348,14 @@ function ElectiveDetailBody({
{selection.studentId}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatElectiveDate(selection.selectedAt)}
{typeof selection.priority === "number"
? selection.priority
: "-"}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatElectiveDate(
selection.enrolledAt ?? selection.selectedAt,
)}
</td>
</tr>
))}

View File

@@ -21,13 +21,16 @@ import { useTranslations } from "next-intl";
import {
useAdminElective,
useAdminUpdateElective,
useGrades,
useTeacherOptions,
} from "@/lib/api/admin-p5";
import { FormPageShell } from "@/shared/components/page-templates";
import { Select } from "@/shared/components/ui/select";
import { notify } from "@/shared/lib/notify";
import {
formatElectiveStatus,
formatElectiveSelectionMode,
getGradeName,
getSubjectName,
type FlexibleElectiveItem,
@@ -35,6 +38,30 @@ import {
/** 状态选项(与 admin.elective.list i18n 对齐) */
const STATUS_OPTIONS = ["DRAFT", "OPEN", "CLOSED", "FULL"] as const;
/** 选课模式选项(@contract-pendingFIRST_COME | LOTTERY */
const SELECTION_MODE_OPTIONS = ["FIRST_COME", "LOTTERY"] as const;
/**
* 将 ISO 时间字符串转换为 datetime-local input 所需的本地时间格式。
* 输入2026-09-01T08:00:00.000Z 或 2026-09-01T08:00:00
* 输出2026-09-01T08:00截断到分钟
* 空值或无效输入返回空字符串。
*/
function isoToDatetimeLocal(iso: string | undefined | null): string {
if (!iso || typeof iso !== "string") return "";
// 取到分钟:截断毫秒和时区,保留 YYYY-MM-DDTHH:mm
const match = iso.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
return match?.[1] ?? "";
}
/**
* 将日期 ISO 字符串转换为 date input 所需的 YYYY-MM-DD 格式。
*/
function isoToDate(iso: string | undefined | null): string {
if (!iso || typeof iso !== "string") return "";
const match = iso.match(/^(\d{4}-\d{2}-\d{2})/);
return match?.[1] ?? "";
}
/**
* 编辑表单客户端主体。需由 server page 包裹在 <Suspense> 中。
@@ -51,6 +78,7 @@ export function ElectiveEditClient(): React.ReactElement {
const { data, loading, error } = useAdminElective(electiveId);
const { data: grades, loading: gradesLoading } = useGrades();
const { data: teachers, loading: teachersLoading } = useTeacherOptions();
const { run: updateElective, loading: updating } = useAdminUpdateElective();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
@@ -58,10 +86,16 @@ export function ElectiveEditClient(): React.ReactElement {
const [gradeId, setGradeId] = useState("");
const [teacherId, setTeacherId] = useState("");
const [capacity, setCapacity] = useState("");
const [credit, setCredit] = useState("");
const [classroom, setClassroom] = useState("");
const [schedule, setSchedule] = useState("");
const [selectionMode, setSelectionMode] = useState<string>("FIRST_COME");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [selectionStartAt, setSelectionStartAt] = useState("");
const [selectionEndAt, setSelectionEndAt] = useState("");
const [dropDeadline, setDropDeadline] = useState("");
const [status, setStatus] = useState<string>("DRAFT");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
@@ -77,6 +111,15 @@ export function ElectiveEditClient(): React.ReactElement {
setCapacity(
typeof data.capacity === "number" ? String(data.capacity) : "",
);
setCredit(typeof data.credit === "number" ? String(data.credit) : "");
setClassroom(data.classroom ?? "");
setSchedule(data.schedule ?? "");
setSelectionMode(data.selectionMode ?? "FIRST_COME");
setStartDate(isoToDate(data.startDate));
setEndDate(isoToDate(data.endDate));
setSelectionStartAt(isoToDatetimeLocal(data.selectionStartAt));
setSelectionEndAt(isoToDatetimeLocal(data.selectionEndAt));
setDropDeadline(isoToDatetimeLocal(data.dropDeadline));
setStatus(data.status ?? "DRAFT");
setInitialized(true);
}
@@ -93,17 +136,41 @@ export function ElectiveEditClient(): React.ReactElement {
setFormError(t("errorSubjectRequired"));
return;
}
const capacityNum = Number.parseInt(capacity, 10);
if (capacity && (!Number.isFinite(capacityNum) || capacityNum <= 0)) {
const capacityNum = capacity ? Number.parseInt(capacity, 10) : undefined;
if (
capacity &&
(capacityNum === undefined ||
!Number.isFinite(capacityNum) ||
capacityNum <= 0)
) {
setFormError(t("errorCapacityInvalid"));
return;
}
setSubmitting(true);
try {
// @contract-pendingupdateElective mutation 契约未补齐
// 当前通过 MSW 兜底模拟提交成功,后端补齐后切换为真实 mutation
await new Promise((resolve) => setTimeout(resolve, 300));
const creditNum = credit ? Number.parseInt(credit, 10) : undefined;
await updateElective({
id: electiveId,
name: name.trim(),
description: description.trim() || undefined,
subjectId: subjectId.trim(),
gradeId,
teacherId: teacherId || undefined,
capacity: capacityNum,
credit:
creditNum !== undefined && Number.isFinite(creditNum)
? creditNum
: undefined,
classroom: classroom.trim() || undefined,
schedule: schedule.trim() || undefined,
selectionMode,
startDate: startDate || undefined,
endDate: endDate || undefined,
selectionStartAt: selectionStartAt || undefined,
selectionEndAt: selectionEndAt || undefined,
dropDeadline: dropDeadline || undefined,
status,
});
notify.success(t("success"));
startTransition(() => {
router.push(`/shell/admin/elective/${electiveId}`);
@@ -111,8 +178,6 @@ export function ElectiveEditClient(): React.ReactElement {
} catch (err) {
setFormError(`${t("error")}: ${String(err)}`);
notify.error(tCommon("error.loadFailed", { message: String(err) }));
} finally {
setSubmitting(false);
}
};
@@ -149,7 +214,7 @@ export function ElectiveEditClient(): React.ReactElement {
icon={<BookOpen className="size-6" />}
backHref={`/shell/admin/elective/${electiveId}`}
onSubmit={handleSubmit}
submitting={submitting}
submitting={updating}
submitLabel={t("submit")}
cancelLabel={t("cancel")}
errorSummary={
@@ -196,42 +261,46 @@ export function ElectiveEditClient(): React.ReactElement {
{/* 年级 */}
<FormField label={t("fieldGradeId")}>
<select
<Select
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
onValueChange={setGradeId}
options={[
{
value: "",
label: gradesLoading ? tCommon("loading") : t("fieldGradeId"),
},
...(grades ?? []).map((g) => ({
value: g.id,
label: g.name,
})),
]}
disabled={gradesLoading}
>
<option value="">
{gradesLoading ? tCommon("loading") : t("fieldGradeId")}
</option>
{(grades ?? []).map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
className="h-9 w-full"
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 教师 */}
<FormField label={t("fieldTeacherId")}>
<select
<Select
value={teacherId}
onChange={(e) => setTeacherId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
onValueChange={setTeacherId}
options={[
{
value: "",
label: teachersLoading
? tCommon("loading")
: t("fieldTeacherId"),
},
...(teachers ?? []).map((teacher) => ({
value: teacher.id,
label: teacher.name,
})),
]}
disabled={teachersLoading}
>
<option value="">
{teachersLoading ? tCommon("loading") : t("fieldTeacherId")}
</option>
{(teachers ?? []).map((teacher) => (
<option key={teacher.id} value={teacher.id}>
{teacher.name}
</option>
))}
</select>
className="h-9 w-full"
/>
</FormField>
{/* 容量 */}
@@ -247,6 +316,58 @@ export function ElectiveEditClient(): React.ReactElement {
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 学分 */}
<FormField label={t("fieldCredit")}>
<input
type="number"
min={0}
step={0.5}
value={credit}
onChange={(e) => setCredit(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="2"
/>
</FormField>
{/* 选课模式 */}
<FormField label={t("fieldSelectionMode")}>
<Select
value={selectionMode}
onValueChange={setSelectionMode}
options={SELECTION_MODE_OPTIONS.map((m) => ({
value: m,
label: formatElectiveSelectionMode(m),
}))}
className="h-9 w-full"
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 教室 */}
<FormField label={t("fieldClassroom")}>
<input
type="text"
value={classroom}
onChange={(e) => setClassroom(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("fieldClassroom")}
/>
</FormField>
{/* 上课时间 */}
<FormField label={t("fieldSchedule")}>
<input
type="text"
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("fieldSchedule")}
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 开始日期 */}
<FormField label={t("fieldStartDate")}>
@@ -269,20 +390,52 @@ export function ElectiveEditClient(): React.ReactElement {
</FormField>
</div>
{/* 状态 */}
<FormField label={t("fieldStatus")}>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{formatElectiveStatus(s)}
</option>
))}
</select>
</FormField>
<div className="grid grid-cols-2 gap-4">
{/* 选课开始时间 */}
<FormField label={t("fieldSelectionStartAt")}>
<input
type="datetime-local"
value={selectionStartAt}
onChange={(e) => setSelectionStartAt(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
{/* 选课结束时间 */}
<FormField label={t("fieldSelectionEndAt")}>
<input
type="datetime-local"
value={selectionEndAt}
onChange={(e) => setSelectionEndAt(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 退课截止时间 */}
<FormField label={t("fieldDropDeadline")}>
<input
type="datetime-local"
value={dropDeadline}
onChange={(e) => setDropDeadline(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
{/* 状态 */}
<FormField label={t("fieldStatus")}>
<Select
value={status}
onValueChange={setStatus}
options={STATUS_OPTIONS.map((s) => ({
value: s,
label: formatElectiveStatus(s),
}))}
className="h-9 w-full"
/>
</FormField>
</div>
</FormPageShell>
);
}

View File

@@ -5,6 +5,8 @@
*
* 数据契约:
* - 列表查询 adminElectives❌ schema 无 → MSW 兜底(@contract-pending
* - 总览统计 electiveOverviewStats❌ schema 无 → MSW 兜底(@contract-pending
* - 业务动作 open/close/lottery/delete❌ schema 无 → MSW 兜底(@contract-pending
*
* URL 状态:?search=&status=&page=
*
@@ -12,26 +14,50 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { BookOpen } from "lucide-react";
import { BookOpen, Layers, Sparkles, Ticket, Users } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useAdminElectives } from "@/lib/api/admin-p5";
import {
useAdminElectives,
useCloseElectiveSelection,
useDeleteElective,
useGetElectiveOverviewStats,
useOpenElectiveSelection,
useRunElectiveLottery,
} from "@/lib/api/admin-p5";
import type { AdminElectiveListItem } from "@/lib/api/admin-p5";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button";
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Select } from "@/shared/components/ui/select";
import { StatCard } from "@/shared/components/ui/stat-card";
import { StatsGrid } from "@/shared/components/ui/stats-grid";
import { notify } from "@/shared/lib/notify";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
} from "@/shared/components/page-templates/list-page";
import {
calcEnrollmentRate,
electiveStatusToBadgeClass,
enrollmentRateToColorClass,
formatEnrollmentCount,
formatElectiveSelectionMode,
formatElectiveStatus,
formatEnrollmentCount,
getEnrolledCount,
getGradeName,
getSubjectName,
@@ -41,9 +67,14 @@ import {
type FlexibleElectiveItem,
} from "@/features/admin/elective/transformations";
/** 状态筛选选项(与 admin.elective.list i18n 对齐) */
const STATUS_OPTIONS = ["DRAFT", "OPEN", "CLOSED", "FULL"] as const;
type ConfirmAction =
| { kind: "open"; id: string; name: string }
| { kind: "close"; id: string; name: string }
| { kind: "lottery"; id: string; name: string }
| null;
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
@@ -63,13 +94,28 @@ export function ElectiveListClient(): React.ReactElement {
// @contract-pendingMSW 兜底
const { data, loading, error } = useAdminElectives();
const { data: stats, loading: statsLoading } = useGetElectiveOverviewStats();
// 业务动作 hooks
const { run: deleteElective, loading: deleting } = useDeleteElective();
const { run: openSelection, loading: opening } = useOpenElectiveSelection();
const { run: closeSelection, loading: closing } = useCloseElectiveSelection();
const { run: runLottery, loading: lotterying } = useRunElectiveLottery();
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
const [deleteTarget, setDeleteTarget] = useState<{
id: string;
name: string;
} | null>(null);
// 客户端二次筛选search + status
const filteredItems = useMemo<FlexibleElectiveItem[]>(() => {
const items = (data?.items ?? []) as FlexibleElectiveItem[];
const filteredItems = useMemo<AdminElectiveListItem[]>(() => {
const items = data?.items ?? [];
return items.filter((item) => {
const flexible = item as unknown as FlexibleElectiveItem;
return (
matchElectiveSearch(item, search) && matchElectiveStatus(item, status)
matchElectiveSearch(flexible, search) &&
matchElectiveStatus(flexible, status)
);
});
}, [data, search, status]);
@@ -81,7 +127,6 @@ export function ElectiveListClient(): React.ReactElement {
} else {
params.delete(key);
}
// 切换筛选时重置页码
if (key === "status" || key === "search") {
params.delete("page");
}
@@ -90,6 +135,45 @@ export function ElectiveListClient(): React.ReactElement {
});
};
const handleConfirmAction = async (): Promise<void> => {
if (!confirmAction) return;
const { kind, id } = confirmAction;
try {
if (kind === "open") {
await openSelection(id);
notify.success(t("openSuccess"));
} else if (kind === "close") {
await closeSelection(id);
notify.success(t("closeSuccess"));
} else if (kind === "lottery") {
await runLottery(id);
notify.success(t("lotterySuccess"));
}
setConfirmAction(null);
router.refresh();
} catch (err) {
if (kind === "lottery") {
notify.error(t("lotteryFailed"));
}
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleConfirmDelete = async (): Promise<void> => {
if (!deleteTarget) return;
try {
await deleteElective(deleteTarget.id);
notify.success(t("deleteSuccess"));
setDeleteTarget(null);
router.refresh();
} catch (err) {
notify.error(t("deleteFailed"));
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const actionLoading = opening || closing || lotterying;
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
@@ -105,7 +189,7 @@ export function ElectiveListClient(): React.ReactElement {
title={t("emptyTitle")}
description={t("emptyDescription")}
action={{
label: t("createButton"),
label: t("emptyAction"),
href: "/shell/admin/elective/create",
}}
/>
@@ -130,19 +214,19 @@ export function ElectiveListClient(): React.ReactElement {
/>
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("statusFilter")}</span>
<select
<Select
value={status}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
onValueChange={(v) => updateQuery("status", v)}
options={[
{ value: "", label: t("allStatuses") },
...STATUS_OPTIONS.map((s) => ({
value: s,
label: formatElectiveStatus(s),
})),
]}
className="h-9 w-40"
aria-label={t("statusFilter")}
>
<option value="">{t("allStatuses")}</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{formatElectiveStatus(s)}
</option>
))}
</select>
/>
</label>
</>
}
@@ -159,7 +243,105 @@ export function ElectiveListClient(): React.ReactElement {
</div>
}
>
<ElectiveTable items={filteredItems} page={page} />
<div className="space-y-4">
<StatsGrid columns={5} className="not-print">
<StatCard
title={t("statTotalCourses")}
value={stats?.totalCourses ?? 0}
icon={BookOpen}
isLoading={statsLoading}
/>
<StatCard
title={t("statTotalCapacity")}
value={stats?.totalCapacity ?? 0}
icon={Layers}
isLoading={statsLoading}
/>
<StatCard
title={t("statTotalEnrolled")}
value={stats?.totalEnrolled ?? 0}
icon={Users}
isLoading={statsLoading}
/>
<StatCard
title={t("statTotalDraft")}
value={stats?.totalDraft ?? 0}
icon={Ticket}
isLoading={statsLoading}
/>
<StatCard
title={t("statTotalOpen")}
value={stats?.totalOpen ?? 0}
icon={Sparkles}
highlight
isLoading={statsLoading}
/>
</StatsGrid>
<ElectivesTable
items={filteredItems}
page={page}
onOpen={(id, name) => setConfirmAction({ kind: "open", id, name })}
onClose={(id, name) => setConfirmAction({ kind: "close", id, name })}
onLottery={(id, name) =>
setConfirmAction({ kind: "lottery", id, name })
}
onDelete={(id, name) => setDeleteTarget({ id, name })}
/>
</div>
<AlertDialog
open={confirmAction !== null}
onOpenChange={(open) => {
if (!open) setConfirmAction(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{confirmAction?.kind === "open"
? t("confirmOpenTitle")
: confirmAction?.kind === "close"
? t("confirmCloseTitle")
: t("confirmLotteryTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{confirmAction?.kind === "open"
? t("confirmOpenDescription")
: confirmAction?.kind === "close"
? t("confirmCloseDescription")
: t("confirmLotteryDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={actionLoading}>
{t("confirmCancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={actionLoading}
onClick={(e) => {
e.preventDefault();
void handleConfirmAction();
}}
>
{actionLoading ? t("confirming") : t("confirmSubmit")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDeleteDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
title={t("confirmDeleteTitle")}
description={t("confirmDeleteDescription")}
confirmText={t("delete")}
cancelText={t("confirmCancel")}
onConfirm={handleConfirmDelete}
isWorking={deleting}
/>
</ListPageShell>
);
}
@@ -167,12 +349,20 @@ export function ElectiveListClient(): React.ReactElement {
/**
* 选修课列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function ElectiveTable({
function ElectivesTable({
items,
page,
onOpen,
onClose,
onLottery,
onDelete,
}: {
items: FlexibleElectiveItem[];
items: AdminElectiveListItem[];
page: number;
onOpen: (id: string, name: string) => void;
onClose: (id: string, name: string) => void;
onLottery: (id: string, name: string) => void;
onDelete: (id: string, name: string) => void;
}): React.ReactElement {
const t = useTranslations("admin.elective.list");
return (
@@ -185,14 +375,20 @@ function ElectiveTable({
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
<th className="p-3 text-left font-medium">{t("colTeacher")}</th>
<th className="p-3 text-left font-medium">{t("colEnrolled")}</th>
<th className="p-3 text-left font-medium">{t("colClassroom")}</th>
<th className="p-3 text-left font-medium">{t("colSchedule")}</th>
<th className="p-3 text-left font-medium">{t("colCredit")}</th>
<th className="p-3 text-left font-medium">
{t("colSelectionMode")}
</th>
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => {
const id = item.id ?? "";
const enrolled = getEnrolledCount(item);
const flexible = item as unknown as FlexibleElectiveItem;
const enrolled = getEnrolledCount(flexible);
const capacity =
typeof item.capacity === "number" &&
Number.isFinite(item.capacity)
@@ -202,59 +398,105 @@ function ElectiveTable({
capacity,
enrolledCount: enrolled,
});
const status = item.status ?? "";
const selectionMode = item.selectionMode ?? "";
return (
<tr key={id} className="hover:bg-muted/30">
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/admin/elective/${id}`}
href={`/shell/admin/elective/${item.id}?page=${page}`}
className="font-medium hover:underline"
>
{item.name ?? "-"}
{item.name}
</Link>
</td>
<td className="p-3 text-muted-foreground">
{getSubjectName(item)}
{getSubjectName(flexible)}
</td>
<td className="p-3 text-muted-foreground">
{getGradeName(item)}
{getGradeName(flexible)}
</td>
<td className="p-3 text-muted-foreground">
{item.teacherName || "-"}
</td>
<td className="p-3">
<div className="flex flex-col gap-0.5">
<span
className={`font-mono text-xs font-medium ${enrollmentRateToColorClass(
rate,
)}`}
>
{formatEnrollmentCount({
capacity,
enrolledCount: enrolled,
})}
</span>
<span className="font-mono text-[10px] text-muted-foreground">
{rate}%
</span>
</div>
<span
className={`font-mono text-xs font-medium ${enrollmentRateToColorClass(
rate,
)}`}
>
{formatEnrollmentCount({
capacity,
enrolledCount: enrolled,
})}
</span>
</td>
<td className="p-3 text-muted-foreground">
{item.classroom || "-"}
</td>
<td className="p-3 text-muted-foreground">
{item.schedule || "-"}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{typeof item.credit === "number" ? item.credit : "-"}
</td>
<td className="p-3">
<StatusBadge status={item.status ?? ""} />
<SelectionModeBadge mode={selectionMode} />
</td>
<td className="p-3">
<StatusBadge status={status} />
</td>
<td className="p-3">
<div className="flex items-center justify-end gap-1">
<Button asChild variant="ghost" size="sm">
<Link href={`/shell/admin/elective/${id}?page=${page}`}>
<Link
href={`/shell/admin/elective/${item.id}?page=${page}`}
>
{t("viewDetail")}
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link
href={`/shell/admin/elective/${id}/edit?page=${page}`}
href={`/shell/admin/elective/${item.id}/edit?page=${page}`}
>
{t("edit")}
</Link>
</Button>
{status === "DRAFT" ? (
<Button
variant="outline"
size="sm"
onClick={() => onOpen(item.id, item.name)}
>
{t("openSelection")}
</Button>
) : null}
{status === "OPEN" || status === "FULL" ? (
<Button
variant="outline"
size="sm"
onClick={() => onClose(item.id, item.name)}
>
{t("closeSelection")}
</Button>
) : null}
{selectionMode === "LOTTERY" && status !== "DRAFT" ? (
<Button
variant="outline"
size="sm"
onClick={() => onLottery(item.id, item.name)}
>
{t("runLottery")}
</Button>
) : null}
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => onDelete(item.id, item.name)}
>
{t("delete")}
</Button>
</div>
</td>
</tr>
@@ -267,7 +509,28 @@ function ElectiveTable({
}
/**
* 选修课状态徽章(按状态色阶展示)
* 选课模式徽章
*/
function SelectionModeBadge({ mode }: { mode: string }): React.ReactElement {
if (!mode) {
return <span className="text-xs text-muted-foreground">-</span>;
}
const label = formatElectiveSelectionMode(mode);
const cls =
mode === "LOTTERY"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400";
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}
/**
* 状态徽章(按状态色阶展示)。
*/
function StatusBadge({ status }: { status: string }): React.ReactElement {
const label = formatElectiveStatus(status);

View File

@@ -40,6 +40,20 @@ export interface FlexibleElectiveItem {
endDate?: string;
createdAt?: string;
updatedAt?: string;
/** 教室(@contract-pending对齐 CICD 列表"教室"列) */
classroom?: string;
/** 上课时间(@contract-pending对齐 CICD 列表"时间"列) */
schedule?: string;
/** 学分(@contract-pending对齐 CICD 列表"学分"列) */
credit?: number;
/** 选课模式(@contract-pendingFIRST_COME | LOTTERY */
selectionMode?: string;
/** 选课开始时间 ISO@contract-pending */
selectionStartAt?: string;
/** 选课结束时间 ISO@contract-pending */
selectionEndAt?: string;
/** 退课截止时间 ISO@contract-pending */
dropDeadline?: string;
}
/**
@@ -49,6 +63,20 @@ export interface FlexibleElectiveItem {
export function formatElectiveStatus(status: string): string {
return ELECTIVE_STATUS_LABEL[status] ?? status;
}
/** 选课模式枚举(@contract-pending */
export type ElectiveSelectionMode = "FIRST_COME" | "LOTTERY";
/** 选课模式中文标签映射(与 admin.elective.list i18n 对齐) */
export const ELECTIVE_SELECTION_MODE_LABEL: Record<string, string> = {
FIRST_COME: "先到先得",
LOTTERY: "抽签",
};
/**
* 将选课模式枚举值映射为中文标签。
* 未知模式回退为原始值。
*/
export function formatElectiveSelectionMode(mode: string): string {
return ELECTIVE_SELECTION_MODE_LABEL[mode] ?? mode;
}
/**
* 根据选修课状态返回 Tailwind 徽章语义类名。

View File

@@ -52,6 +52,22 @@ const baseStats: AdminErrorBookStats = {
errorRate: 0.2,
},
],
byClass: [
{
classId: "cls-1",
className: "高三(1)班",
errorCount: 100,
questionCount: 30,
errorRate: 0.7,
},
{
classId: "cls-2",
className: "高三(2)班",
errorCount: 60,
questionCount: 20,
errorRate: 0.3,
},
],
topStudents: [
{
studentId: "stu-1",
@@ -304,6 +320,7 @@ describe("hasErrorBookData", () => {
totalErrorCount: 0,
avgErrorRate: 0,
bySubject: [],
byClass: [],
topStudents: [],
topWrongQuestions: [],
};

View File

@@ -5,27 +5,32 @@
*
* 数据契约:
* - adminErrorBookStats():❌ schema 无 → MSW 兜底(@contract-pending
* - exportErrorBookCsv(filter):❌ schema 无 → MSW 兜底(@contract-pending
*
* URL 状态:?subjectId=xxx
* URL 状态:?subjectId=xxx&page=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { BookX } from "lucide-react";
import { BookX, Download } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useAdminErrorBookStats } from "@/lib/api";
import { useAdminErrorBookStats, useExportErrorBookCsv } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { StatCard } from "@/shared/components/ui/stat-card";
import { notify } from "@/shared/lib/notify";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
buildPageList,
computeTotalPages,
countHighFreqErrors,
errorRateToColorClass,
filterStatsBySubject,
@@ -35,11 +40,18 @@ import {
getSubjectTabs,
hasErrorBookData,
isHighFreqError,
sortClassesByErrorRate,
sortSubjectsByErrorRate,
topWrongQuestions,
truncateContent,
withStudentRank,
} from "@/features/admin/error-book/transformations";
import { ErrorBookDetailDialog } from "@/features/admin/error-book/error-book-detail-dialog";
/** 高频错题列表每页条数 */
const PAGE_SIZE = 10;
/** 分页组件最多展示的页码按钮数(奇数,便于左右对称) */
const MAX_PAGE_BUTTONS = 7;
/**
* 错题本分析客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -51,11 +63,14 @@ export function ErrorBookClient(): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const subjectId = searchParams.get("subjectId") ?? "";
const page = Number(searchParams.get("page") ?? "1") || 1;
// @contract-pendingMSW 兜底
const { data: rawStats, loading, error } = useAdminErrorBookStats();
const { run: runExport, loading: exporting } = useExportErrorBookCsv();
const stats = useMemo(
() => filterStatsBySubject(rawStats ?? null, subjectId),
@@ -74,11 +89,48 @@ export function ErrorBookClient(): React.ReactElement {
} else {
params.delete("subjectId");
}
// 切换学科时重置页码
params.delete("page");
startTransition(() => {
router.push(`/shell/admin/error-book?${params.toString()}`);
});
};
const updatePage = (next: number): void => {
const params = new URLSearchParams(searchParams.toString());
if (next > 1) {
params.set("page", String(next));
} else {
params.delete("page");
}
startTransition(() => {
router.push(`/shell/admin/error-book?${params.toString()}`);
});
};
const handleExportCsv = async (): Promise<void> => {
try {
const filter = {
subjectId: subjectId || null,
classId: null,
};
const result = await runExport(filter);
// 触发浏览器下载(与 audit-logs 导出模式一致)
const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = result.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
notify.success(t("exportSuccess", { count: result.count }));
} catch (e) {
notify.error(tCommon("error.loadFailed", { message: String(e) }));
}
};
const hasData = hasErrorBookData(stats);
const errorNode = error ? (
@@ -103,6 +155,16 @@ export function ErrorBookClient(): React.ReactElement {
title={t("title")}
description={t("description")}
icon={<BookX className="size-6" />}
actions={
<Button
variant="outline"
onClick={() => void handleExportCsv()}
disabled={exporting || !hasData}
>
<Download className="size-4" />
{exporting ? t("exporting") : t("exportCsv")}
</Button>
}
filters={
<SubjectTabs
subjectId={subjectId}
@@ -120,10 +182,20 @@ export function ErrorBookClient(): React.ReactElement {
{stats ? (
<ErrorBookContent
stats={stats}
rawStats={rawStats ?? null}
highFreqTotal={countHighFreqErrors(rawStats?.topWrongQuestions)}
page={page}
onPageChange={updatePage}
onViewDetail={setSelectedItemId}
/>
) : null}
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
<ErrorBookDetailDialog
itemId={selectedItemId}
onOpenChange={(open) => {
if (!open) setSelectedItemId(null);
}}
/>
</ListPageShell>
);
}
@@ -188,14 +260,24 @@ function TabButton({
}
/**
* 错题本分析主体内容(统计卡片 + 学科分布 + 薄弱点 + Top 学生 + Top 错题)。
* 错题本分析主体内容(统计卡片 + 学科分布 + 班级分布 + 薄弱点 + Top 学生 + Top 错题)。
*/
function ErrorBookContent({
stats,
rawStats,
highFreqTotal,
page,
onPageChange,
onViewDetail,
}: {
stats: NonNullable<ReturnType<typeof useAdminErrorBookStats>["data"]>;
rawStats: NonNullable<
ReturnType<typeof useAdminErrorBookStats>["data"]
> | null;
highFreqTotal: number;
page: number;
onPageChange: (next: number) => void;
onViewDetail: (itemId: string) => void;
}): React.ReactElement {
const t = useTranslations("admin.errorBook.list");
const avgPerStudent = formatAvgPerStudent(
@@ -204,8 +286,22 @@ function ErrorBookContent({
);
const subjectDistribution = sortSubjectsByErrorRate(stats);
const classDistribution = sortClassesByErrorRate(rawStats);
const rankedStudents = withStudentRank(stats.topStudents).slice(0, 50);
const wrongQuestions = topWrongQuestions(stats.topWrongQuestions, 10);
// 错题列表分页:基于 rawStats.topWrongQuestions 全量分页,避免学科筛选下截断
const allWrongQuestions = topWrongQuestions(
rawStats?.topWrongQuestions,
1000,
);
const totalWrong = allWrongQuestions.length;
const totalPages = computeTotalPages(totalWrong, PAGE_SIZE);
const safePage = Math.min(Math.max(1, page), totalPages);
const startIdx = (safePage - 1) * PAGE_SIZE;
const pagedWrongQuestions = allWrongQuestions.slice(
startIdx,
startIdx + PAGE_SIZE,
);
return (
<div className="flex flex-col gap-6">
@@ -224,52 +320,100 @@ function ErrorBookContent({
/>
</div>
{/* 学科分布 */}
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("distributionTitle")}
</h2>
{subjectDistribution.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyTitle")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("distributionSubject")}
</th>
<th className="p-2 text-left font-medium">
{t("distributionCount")}
</th>
<th className="p-2 text-left font-medium">
{t("knowledgeWeaknessErrorRate")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{subjectDistribution.map((s) => (
<tr key={s.subjectId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{s.subjectName}</td>
<td className="p-2 font-mono text-xs">
{formatErrorCount(s.errorCount)}
</td>
<td
className={`p-2 font-mono text-xs ${errorRateToColorClass(s.errorRate)}`}
>
{formatErrorRate(s.errorRate)}
</td>
{/* 学科分布 + 班级分布并排展示lg 以上双列) */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("distributionTitle")}
</h2>
{subjectDistribution.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyTitle")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("distributionSubject")}
</th>
<th className="p-2 text-left font-medium">
{t("distributionCount")}
</th>
<th className="p-2 text-left font-medium">
{t("knowledgeWeaknessErrorRate")}
</th>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</thead>
<tbody className="divide-y">
{subjectDistribution.map((s) => (
<tr key={s.subjectId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{s.subjectName}</td>
<td className="p-2 font-mono text-xs">
{formatErrorCount(s.errorCount)}
</td>
<td
className={`p-2 font-mono text-xs ${errorRateToColorClass(s.errorRate)}`}
>
{formatErrorRate(s.errorRate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("classDistributionTitle")}
</h2>
{classDistribution.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyTitle")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("classDistributionClass")}
</th>
<th className="p-2 text-left font-medium">
{t("classDistributionCount")}
</th>
<th className="p-2 text-left font-medium">
{t("knowledgeWeaknessErrorRate")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{classDistribution.map((c) => (
<tr key={c.classId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{c.className}</td>
<td className="p-2 font-mono text-xs">
{formatErrorCount(c.errorCount)}
</td>
<td
className={`p-2 font-mono text-xs ${errorRateToColorClass(c.errorRate)}`}
>
{formatErrorRate(c.errorRate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
{/* 章节薄弱点 + 知识点薄弱点(基于当前契约数据派生) */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
@@ -328,13 +472,18 @@ function ErrorBookContent({
</CardContent>
</Card>
{/* Top 10 高频错题 */}
{/* 错题列表(分页) */}
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("topWrongQuestionsTitle")}
</h2>
{wrongQuestions.length === 0 ? (
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">
{t("topWrongQuestionsTitle")}
</h2>
<span className="text-xs text-muted-foreground">
{t("total", { count: totalWrong })}
</span>
</div>
{pagedWrongQuestions.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyTitle")}
</p>
@@ -352,10 +501,13 @@ function ErrorBookContent({
<th className="p-2 text-left font-medium">
{t("knowledgeWeaknessErrorRate")}
</th>
<th className="p-2 text-left font-medium">
{t("topWrongQuestionsActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{wrongQuestions.map((q) => (
{pagedWrongQuestions.map((q) => (
<tr key={q.questionId} className="hover:bg-muted/30">
<td className="p-2">{truncateContent(q.content, 80)}</td>
<td className="p-2 font-mono text-xs">
@@ -371,12 +523,31 @@ function ErrorBookContent({
</span>
) : null}
</td>
<td className="p-2">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => onViewDetail(q.questionId)}
>
{t("viewDetail")}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 分页 */}
{totalPages > 1 ? (
<Pagination
page={safePage}
pageSize={PAGE_SIZE}
total={totalWrong}
onJump={onPageChange}
/>
) : null}
</CardContent>
</Card>
</div>
@@ -488,3 +659,80 @@ function KnowledgeWeaknessCard({
</Card>
);
}
/**
* 分页组件(页码列表 + 跳转按钮 + total/totalPages 显示)。
* 依赖 URL ?page=N 状态,由父组件控制路由跳转。
* 页码按钮策略:当 totalPages ≤ MAX_PAGE_BUTTONS 时全量展示;
* 超过时展示首尾页 + 当前页附近的页码(含省略号占位)。
*/
function Pagination({
page,
pageSize,
total,
onJump,
}: {
page: number;
pageSize: number;
total: number;
onJump: (page: number) => void;
}): React.ReactElement {
const t = useTranslations("admin.errorBook.list");
const tCommon = useTranslations("common");
const totalPages = computeTotalPages(total, pageSize);
const canPrev = page > 1;
const canNext = page < totalPages;
const pages = buildPageList(page, totalPages, MAX_PAGE_BUTTONS);
return (
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span>{t("total", { count: total })}</span>
<span className="text-xs">
{page} / {totalPages}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onJump(Math.max(1, page - 1))}
disabled={!canPrev}
aria-label={tCommon("button.prev")}
>
{tCommon("button.prev")}
</Button>
{pages.map((p, idx) =>
p === "..." ? (
<span
key={`gap-${idx}`}
className="px-2 text-xs text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="sm"
onClick={() => onJump(p)}
aria-current={p === page ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="sm"
onClick={() => onJump(Math.min(totalPages, page + 1))}
disabled={!canNext}
aria-label={tCommon("button.next")}
>
{tCommon("button.next")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,267 @@
"use client";
/**
* 错题详情对话框(迁移自 CICD error-book-detail-dialog.tsx
*
* 适配 portal-shell
* - 用原生轻量模态fixed inset-0 + bg-black/50 + 卡片)替代 shadcn Dialog
* - 数据通过 useErrorBookDetail hook@contract-pending MSW 兜底)拉取
* - 仅展示详情(无 archive/delete/saveNote 等 server action
* - 错误处理走 notify.error()
*
* 关联ARCHITECTURE.md §5.3 契约纪律 / §9.4 / §11.3 DoD
*/
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import {
BookOpen,
Calendar,
GraduationCap,
Hash,
Lightbulb,
RefreshCw,
Target,
User,
} from "lucide-react";
import { useErrorBookDetail } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { Separator } from "@/shared/components/ui/separator";
export interface ErrorBookDetailDialogProps {
/** 当前选中的错题 itemId为空时关闭对话框 */
itemId: string | null;
onOpenChange: (open: boolean) => void;
}
/**
* 错题详情对话框。itemId 非空时打开,按 itemId 拉取详情。
*/
export function ErrorBookDetailDialog({
itemId,
onOpenChange,
}: ErrorBookDetailDialogProps): React.ReactElement {
const t = useTranslations("admin.errorBook.detailDialog");
const tCommon = useTranslations("common");
const open = itemId !== null && itemId.length > 0;
const { data, loading, error, refetch } = useErrorBookDetail(itemId ?? "", {
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="error-book-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="error-book-detail-title"
className="flex items-center gap-2 text-lg font-semibold"
>
<BookOpen className="size-5" />
{t("title")}
</h2>
{data ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
{data.subjectName ? (
<Badge variant="outline">{data.subjectName}</Badge>
) : null}
{data.knowledgePointTitle ? (
<Badge variant="secondary">{data.knowledgePointTitle}</Badge>
) : null}
{data.className ? (
<span className="text-muted-foreground">
{data.className}
</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={<User className="size-4" />}
label={t("studentName")}
value={data.studentName}
/>
<InfoItem
icon={<GraduationCap className="size-4" />}
label={t("className")}
value={data.className}
/>
<InfoItem
icon={<Hash className="size-4" />}
label={t("errorCount")}
value={String(data.errorCount)}
/>
</section>
<Separator />
{/* 题目内容 */}
<section>
<h3 className="mb-2 text-sm font-medium">{t("question")}</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.correctAnswer ? (
<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("correctAnswer")}
</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.correctAnswer}
</pre>
</div>
</section>
) : null}
{/* 解析 */}
{data.analysis ? (
<section>
<h3 className="mb-2 flex items-center gap-1 text-sm font-medium">
<Lightbulb className="size-4" />
{t("analysis")}
</h3>
<div className="rounded-md border bg-background p-3 text-sm">
<pre className="whitespace-pre-wrap break-words font-sans">
{data.analysis}
</pre>
</div>
</section>
) : null}
{/* 元信息 */}
<section className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="size-3" />
{t("lastErrorTime")}{formatDateTime(data.lastErrorTime)}
</span>
{data.knowledgePointTitle ? (
<span className="flex items-center gap-1">
<BookOpen className="size-3" />
{t("knowledgePoint")}{data.knowledgePointTitle}
</span>
) : null}
</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: string;
}): 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>
);
}
/** 格式化 ISO 日期为本地化展示。 */
function formatDateTime(iso: string): string {
if (!iso) return "--";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN");
}

View File

@@ -123,6 +123,60 @@ export function sortSubjectsByErrorRate(
return [...stats.bySubject].sort((a, b) => b.errorRate - a.errorRate);
}
/** 班级维度统计项(从 AdminErrorBookStats.byClass 派生) */
type ClassStat = AdminErrorBookStats["byClass"][number];
/**
* 按错误率降序排序的班级分布列表。
*/
export function sortClassesByErrorRate(
stats: AdminErrorBookStats | null | undefined,
): ClassStat[] {
if (!stats?.byClass) return [];
return [...stats.byClass].sort((a, b) => b.errorRate - a.errorRate);
}
/**
* 计算分页后的总页数。
* pageSize ≤ 0 视为 1total ≤ 0 视为 0 条。
*/
export function computeTotalPages(total: number, pageSize: number): number {
const safeTotal = Math.max(0, Math.floor(total));
const safeSize = Math.max(1, Math.floor(pageSize));
return Math.max(1, Math.ceil(safeTotal / safeSize));
}
/**
* 构造页码列表:当总页数不超过 maxButtons 时全部展示;
* 否则展示首尾页与当前页附近页码,省略位置用 "..." 占位。
*/
export function buildPageList(
current: number,
total: number,
maxButtons: number,
): Array<number | "..."> {
if (total <= maxButtons) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const half = Math.floor(maxButtons / 2);
const start = Math.max(2, current - half + 1);
const end = Math.min(total - 1, start + maxButtons - 4);
const adjustedStart =
end - start < maxButtons - 4 ? Math.max(2, end - (maxButtons - 5)) : start;
const result: Array<number | "..."> = [1];
if (adjustedStart > 2) {
result.push("...");
}
for (let p = adjustedStart; p <= end; p += 1) {
result.push(p);
}
if (end < total - 1) {
result.push("...");
}
result.push(total);
return result;
}
/**
* 给 topStudents 列表注入排名字段rank 从 1 开始)。
*/

View File

@@ -7,6 +7,8 @@ import { describe, expect, it } from "vitest";
import {
MIME_TYPE_LABEL,
categorizeFileByName,
categoryToIconColor,
formatFileDate,
formatFileSize,
formatMimeType,
@@ -93,3 +95,42 @@ describe("mimeTypeToCategory", () => {
expect(mimeTypeToCategory("/png")).toBe("unknown");
});
});
describe("categoryToIconColor", () => {
it("maps image category to emerald", () => {
expect(categoryToIconColor("image")).toContain("emerald");
});
it("maps document category to blue", () => {
expect(categoryToIconColor("document")).toContain("blue");
});
it("maps video category to purple", () => {
expect(categoryToIconColor("video")).toContain("purple");
});
it("maps audio category to amber", () => {
expect(categoryToIconColor("audio")).toContain("amber");
});
it("maps other category to muted", () => {
expect(categoryToIconColor("other")).toBe("text-muted-foreground");
});
});
describe("categorizeFileByName", () => {
it("categorizes image files by extension", () => {
expect(categorizeFileByName("photo.png")).toBe("image");
expect(categorizeFileByName("photo.JPG")).toBe("image");
});
it("categorizes document files by extension", () => {
expect(categorizeFileByName("report.pdf")).toBe("document");
expect(categorizeFileByName("notes.docx")).toBe("document");
});
it("returns other for unknown extensions", () => {
expect(categorizeFileByName("archive.xyz")).toBe("other");
expect(categorizeFileByName("noextension")).toBe("other");
});
});

View File

@@ -0,0 +1,204 @@
"use client";
/**
* 文件批量操作工具栏ARCHITECTURE.md §5.4 / §9.4 / §10 P5
*
* 当选中行数 > 0 时显示,包含:
* - 全选 checkbox含 indeterminate 态)
* - 已选数量提示
* - 清空选择按钮
* - 批量删除按钮(带轻量确认模态)
*
* 调用 useBatchDeleteFiles hook删除中显示 loading 状态。
* 成功后通知 + 通过 onSuccess 回调触发列表刷新。
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5
*/
import { Trash2, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useBatchDeleteFiles } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { notify } from "@/shared/lib/notify";
export interface FileBatchOperationsProps {
/** 选中的文件 ID 集合 */
selectedIds: Set<string>;
/** 是否全选 */
allSelected: boolean;
/** 是否部分选中indeterminate */
someSelected: boolean;
/** 全选/取消全选回调 */
onSelectAll: () => void;
/** 清空选择回调 */
onClearSelection: () => void;
/** 删除成功后的回调(用于触发列表刷新) */
onSuccess?: () => void;
}
/**
* FileBatchOperations文件批量操作工具栏。
*
* 当 selectedIds 为空时返回 null不渲染
* 有选中项时显示工具栏,点击批量删除弹出确认模态。
*/
export function FileBatchOperations({
selectedIds,
allSelected,
someSelected,
onSelectAll,
onClearSelection,
onSuccess,
}: FileBatchOperationsProps): React.ReactElement | null {
const t = useTranslations("admin.files.batch");
const [confirmOpen, setConfirmOpen] = useState(false);
const { run: batchDelete, loading } = useBatchDeleteFiles();
const checkboxRef = useRef<HTMLInputElement | null>(null);
const count = selectedIds.size;
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = someSelected;
}
}, [someSelected]);
if (count === 0) return null;
const handleDelete = async (): Promise<void> => {
const ids = Array.from(selectedIds);
try {
const result = await batchDelete(ids);
notify.success(t("deleteSuccess", { count: result.deletedCount }));
setConfirmOpen(false);
onClearSelection();
onSuccess?.();
} catch (err) {
notify.error(String(err));
setConfirmOpen(false);
}
};
return (
<>
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-4 py-2">
<label className="flex items-center gap-2 text-sm">
<input
ref={checkboxRef}
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
className="size-4 cursor-pointer rounded border-input"
aria-label={t("selectAll")}
/>
<span>{t("selectAll")}</span>
</label>
<span className="text-sm font-medium">
{t("selectedCount", { count })}
</span>
<Button variant="ghost" size="sm" onClick={onClearSelection}>
{t("clearSelection")}
</Button>
<div className="flex-1" />
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmOpen(true)}
disabled={loading}
>
<Trash2 className="size-4" />
{loading ? t("confirming") : t("batchDelete")}
</Button>
</div>
{confirmOpen ? (
<ConfirmDialog
title={t("confirmTitle")}
description={t("confirmDescription", { count })}
cancelLabel={t("confirmCancel")}
submitLabel={t("confirmSubmit")}
confirmingLabel={t("confirming")}
loading={loading}
onCancel={() => setConfirmOpen(false)}
onConfirm={handleDelete}
/>
) : null}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
cancelLabel: string;
submitLabel: string;
confirmingLabel: string;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
}
/**
* 轻量确认模态(自实现,避免引入额外 dialog 依赖)。
*
* - 点击遮罩或按 ESC 关闭loading 期间禁用关闭)
* - destructive 风格确认按钮
*/
function ConfirmDialog({
title,
description,
cancelLabel,
submitLabel,
confirmingLabel,
loading,
onCancel,
onConfirm,
}: ConfirmDialogProps): React.ReactElement {
useEffect(() => {
const handler = (e: KeyboardEvent): void => {
if (e.key === "Escape" && !loading) onCancel();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [loading, onCancel]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => {
if (!loading) onCancel();
}}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div
className="w-full max-w-md rounded-lg bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button
type="button"
onClick={onCancel}
disabled={loading}
className="text-muted-foreground hover:text-foreground"
aria-label={cancelLabel}
>
<X className="size-5" />
</button>
</div>
<p className="mt-3 text-sm text-muted-foreground">{description}</p>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={onCancel} disabled={loading}>
{cancelLabel}
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={loading}>
{loading ? confirmingLabel : submitLabel}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,515 @@
"use client";
/**
* 文件预览对话框ARCHITECTURE.md §5.4 / §9.4 / §10 P5
*
* 基于 CICD 项目 src/modules/files/components/file-preview-dialog.tsx 适配
* 到 portal-shellServer Actions → Apollo Client + MSW 兜底。
*
* 组件层次:
* - FilePreviewDialogDialog 外壳,触发按钮 + 标题 + 内容容器
* - FilePreview根据 mimeType 渲染不同预览体image/pdf/text/office/other
* - FileIcon根据 mimeType 渲染分类图标
*
* 内部 hooks
* - useFilePreviewfetch 文本内容(懒加载,错误重试)
* - useImageZoom图片缩放控制
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5
*/
import {
Download,
Eye,
File as FileLucide,
FileImage,
FileArchive,
FileSpreadsheet,
FileText,
FileType,
Presentation,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import type { ComponentType, ReactNode } from "react";
import type { FileAttachment } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { cn } from "@/shared/lib/utils";
import { formatFileSize, formatMimeType } from "./transformations";
// ============================================================
// FileIcon根据 mimeType 渲染分类图标
// ============================================================
type FileCategory =
| "image"
| "pdf"
| "word"
| "excel"
| "powerpoint"
| "text"
| "archive"
| "other";
const ICON_MAP: Record<FileCategory, ComponentType<{ className?: string }>> = {
image: FileImage,
pdf: FileText,
word: FileText,
excel: FileSpreadsheet,
powerpoint: Presentation,
text: FileType,
archive: FileArchive,
other: FileLucide,
};
const COLOR_MAP: Record<FileCategory, string> = {
image: "text-pink-600",
pdf: "text-red-600",
word: "text-blue-600",
excel: "text-green-600",
powerpoint: "text-orange-600",
text: "text-muted-foreground",
archive: "text-yellow-600",
other: "text-muted-foreground",
};
function resolveCategory(mimeType: string): FileCategory {
if (mimeType.startsWith("image/")) return "image";
if (mimeType === "application/pdf") return "pdf";
if (
mimeType === "application/msword" ||
mimeType ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
) {
return "word";
}
if (
mimeType === "application/vnd.ms-excel" ||
mimeType ===
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
) {
return "excel";
}
if (
mimeType === "application/vnd.ms-powerpoint" ||
mimeType ===
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
) {
return "powerpoint";
}
if (mimeType === "text/plain" || mimeType === "text/markdown") return "text";
if (
mimeType === "application/zip" ||
mimeType === "application/x-rar-compressed"
) {
return "archive";
}
return "other";
}
export function FileIcon({
mimeType,
className,
}: {
mimeType: string;
className?: string;
}): ReactNode {
const category = resolveCategory(mimeType);
const Icon = ICON_MAP[category];
return (
<Icon
className={cn("size-5", COLOR_MAP[category], className)}
aria-hidden="true"
/>
);
}
// ============================================================
// Hooks文本预览 & 图片缩放
// ============================================================
export interface UseFilePreviewReturn {
content: string | null;
error: string | null;
loading: boolean;
load: (url: string) => Promise<void>;
}
/**
* 文本文件预览 hook封装 fetch + 错误处理 + 状态机。
* 错误消息保留原始字符串,由组件层用 i18n 翻译。
*/
export function useFilePreview(): UseFilePreviewReturn {
const [content, setContent] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async (url: string): Promise<void> => {
setLoading(true);
setError(null);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
setContent(text);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load text");
} finally {
setLoading(false);
}
}, []);
return { content, error, loading, load };
}
/**
* 图片预览缩放控制 hook
*/
export function useImageZoom(
initial = 1,
min = 0.25,
max = 4,
): {
zoom: number;
zoomIn: () => void;
zoomOut: () => void;
canZoomIn: boolean;
canZoomOut: boolean;
} {
const [zoom, setZoom] = useState(initial);
const zoomIn = useCallback(
() => setZoom((z) => Math.min(max, z + 0.25)),
[max],
);
const zoomOut = useCallback(
() => setZoom((z) => Math.max(min, z - 0.25)),
[min],
);
return {
zoom,
zoomIn,
zoomOut,
canZoomIn: zoom < max,
canZoomOut: zoom > min,
};
}
// ============================================================
// FilePreview根据 mimeType 渲染不同预览体
// ============================================================
type PreviewKind = "image" | "pdf" | "text" | "office" | "other";
const TEXT_MIME_TYPES = new Set([
"text/plain",
"text/markdown",
"text/csv",
"application/json",
"text/html",
"text/css",
"text/javascript",
]);
const OFFICE_MIME_TYPES = new Set([
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
]);
function classify(mimeType: string): PreviewKind {
if (mimeType.startsWith("image/")) return "image";
if (mimeType === "application/pdf") return "pdf";
if (TEXT_MIME_TYPES.has(mimeType)) return "text";
if (OFFICE_MIME_TYPES.has(mimeType)) return "office";
return "other";
}
interface FilePreviewProps {
file: FileAttachment;
className?: string;
}
export function FilePreview({ file, className }: FilePreviewProps): ReactNode {
const t = useTranslations("admin.files.preview");
const kind = classify(file.mimeType);
const url = file.url ?? "#";
return (
<div className={className}>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<FileIcon mimeType={file.mimeType} className="size-5" />
<div className="min-w-0">
<p className="truncate text-sm font-medium" title={file.name}>
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} · {formatMimeType(file.mimeType)}
</p>
</div>
</div>
<Button asChild variant="outline" size="sm">
<a
href={url}
download={file.name}
target="_blank"
rel="noopener noreferrer"
aria-label={`${t("download")} ${file.name}`}
>
<Download className="mr-2 size-4" aria-hidden="true" />
{t("download")}
</a>
</Button>
</div>
<PreviewBody kind={kind} file={file} url={url} />
</div>
);
}
function PreviewBody({
kind,
file,
url,
}: {
kind: PreviewKind;
file: FileAttachment;
url: string;
}): ReactNode {
if (kind === "image") {
return <ImagePreview url={url} alt={file.name} />;
}
if (kind === "pdf") {
return (
<iframe
src={url}
title={file.name}
aria-label={`PDF preview: ${file.name}`}
className="h-[70vh] w-full rounded-md border"
/>
);
}
if (kind === "text") {
return <TextPreview url={url} />;
}
return <OtherPreview kind={kind} file={file} url={url} />;
}
function ImagePreview({ url, alt }: { url: string; alt: string }): ReactNode {
const t = useTranslations("admin.files.preview");
const { zoom, zoomIn, zoomOut, canZoomIn, canZoomOut } = useImageZoom();
return (
<div className="space-y-2">
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="size-8"
onClick={zoomOut}
disabled={!canZoomOut}
aria-label={t("zoomOut")}
>
<ZoomOut className="size-4" aria-hidden="true" />
</Button>
<span
className="w-12 text-center text-xs text-muted-foreground"
aria-live="polite"
>
{Math.round(zoom * 100)}%
</span>
<Button
type="button"
variant="outline"
size="icon"
className="size-8"
onClick={zoomIn}
disabled={!canZoomIn}
aria-label={t("zoomIn")}
>
<ZoomIn className="size-4" aria-hidden="true" />
</Button>
</div>
<div
className="overflow-auto rounded-md border bg-muted/30 p-2"
style={{ maxHeight: "70vh" }}
>
<img
src={url}
alt={alt}
style={{ transform: `scale(${zoom})`, transformOrigin: "top left" }}
className="mx-auto max-w-full transition-transform"
/>
</div>
</div>
);
}
function TextPreview({ url }: { url: string }): ReactNode {
const t = useTranslations("admin.files.preview.text");
const { content, error, loading, load } = useFilePreview();
if (content === null && !error && !loading) {
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
<FileText
className="size-12 text-muted-foreground"
aria-hidden="true"
/>
<p className="mt-3 text-sm font-medium">{t("title")}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("hint")}</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => void load(url)}
>
{t("load")}
</Button>
</div>
);
}
if (loading) {
return (
<div
role="status"
className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground"
>
{t("loading")}
</div>
);
}
if (error) {
return (
<div
role="alert"
className="flex flex-col items-center justify-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive"
>
<span>{t("error", { message: error })}</span>
<Button variant="outline" size="sm" onClick={() => void load(url)}>
{t("load")}
</Button>
</div>
);
}
return (
<pre className="max-h-[70vh] overflow-auto rounded-md border bg-background p-4 text-xs leading-relaxed">
<code>{content}</code>
</pre>
);
}
function OtherPreview({
kind,
file,
url,
}: {
kind: PreviewKind;
file: FileAttachment;
url: string;
}): ReactNode {
const t = useTranslations("admin.files.preview");
const isOffice = kind === "office";
const title = isOffice ? t("office.title") : t("other.title");
const hint = isOffice ? t("office.hint") : t("other.hint");
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
<FileIcon mimeType={file.mimeType} className="size-12" />
<p className="mt-3 text-sm font-medium">{title}</p>
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
<Button asChild variant="outline" size="sm" className="mt-4">
<a
href={url}
download={file.name}
target="_blank"
rel="noopener noreferrer"
aria-label={`${t("download")} ${file.name}`}
>
<Download className="mr-2 size-4" aria-hidden="true" />
{t("download")}
</a>
</Button>
</div>
);
}
// ============================================================
// FilePreviewDialogDialog 外壳
// ============================================================
interface FilePreviewDialogProps {
file: FileAttachment;
trigger?: ReactNode;
triggerLabel?: string;
triggerVariant?:
"default" | "outline" | "secondary" | "ghost" | "destructive";
triggerSize?: "default" | "sm" | "lg" | "icon";
}
export function FilePreviewDialog({
file,
trigger,
triggerLabel,
triggerVariant = "ghost",
triggerSize = "sm",
}: FilePreviewDialogProps): ReactNode {
const t = useTranslations("admin.files.preview");
return (
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button
type="button"
variant={triggerVariant}
size={triggerSize}
aria-label={t("trigger")}
>
<Eye
className={triggerLabel ? "mr-2 size-4" : "size-4"}
aria-hidden="true"
/>
{triggerLabel ? (
<span>{triggerLabel}</span>
) : (
<span className="sr-only">{t("trigger")}</span>
)}
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] max-w-5xl overflow-hidden">
<DialogHeader>
<DialogTitle className="truncate">{file.name}</DialogTitle>
<DialogDescription>
{t("title")} · {formatMimeType(file.mimeType)}
</DialogDescription>
</DialogHeader>
<div className="max-h-[75vh] overflow-auto">
<FilePreview file={file} />
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,187 @@
"use client";
/**
* 文件上传按钮组件ARCHITECTURE.md §5.4 / §9.4 / §10 P5
*
* 对齐 CICD 项目:
* - 隐藏的 file input + 触发按钮
* - 拖拽上传区域drag-and-drop
* - 上传进度条模拟进度GraphQL mutation 不暴露真实 progress
*
* 调用 useUploadFile hook上传中显示 loading 状态与进度条。
* 成功后通过 onSuccess 回调通知父组件刷新列表。
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5
*/
import { UploadCloud } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useUploadFile, type UploadFileInput } from "@/lib/api";
import { Progress } from "@/shared/components/ui/progress";
import { cn } from "@/shared/lib/utils";
import { notify } from "@/shared/lib/notify";
export interface FileUploadButtonProps {
/** 上传成功后的回调(用于触发列表刷新) */
onSuccess?: () => void;
}
const PROGRESS_INTERVAL_MS = 200;
const PROGRESS_INCREMENT = 10;
const PROGRESS_CAP = 90;
/**
* FileUploadButton单文件上传触发器支持拖拽 + 进度条)。
*
* 点击按钮打开系统文件选择器,或将文件拖拽到按钮区域。
* 选中后构造 UploadFileInput 调用 useUploadFile。
* 上传期间按钮禁用并显示进度条。
*/
export function FileUploadButton({
onSuccess,
}: FileUploadButtonProps): React.ReactElement {
const t = useTranslations("admin.files.upload");
const inputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [dragActive, setDragActive] = useState(false);
const [fileName, setFileName] = useState<string>("");
const { run: uploadFile, loading: hookLoading } = useUploadFile();
const startProgress = useCallback((): (() => void) => {
setProgress(0);
const timer = setInterval(() => {
setProgress((prev) => {
if (prev >= PROGRESS_CAP) return prev;
return Math.min(prev + PROGRESS_INCREMENT, PROGRESS_CAP);
});
}, PROGRESS_INTERVAL_MS);
return () => clearInterval(timer);
}, []);
const handleFile = useCallback(
async (file: File): Promise<void> => {
const input: UploadFileInput = {
filename: file.name,
mimeType: file.type,
size: file.size,
};
setFileName(file.name);
setUploading(true);
const stopProgress = startProgress();
try {
await uploadFile(input);
setProgress(100);
notify.success(t("success"));
onSuccess?.();
} catch (err) {
notify.error(String(err));
} finally {
stopProgress();
setUploading(false);
// 延迟重置进度条,让用户看到 100% 完成态
setTimeout(() => {
setProgress(0);
setFileName("");
}, 600);
// 重置 input value 以便重复选择同一文件
if (inputRef.current) {
inputRef.current.value = "";
}
}
},
[onSuccess, startProgress, t, uploadFile],
);
const handleFileSelect = async (
e: React.ChangeEvent<HTMLInputElement>,
): Promise<void> => {
const file = e.target.files?.[0];
if (!file) return;
await handleFile(file);
};
const handleClick = (): void => {
inputRef.current?.click();
};
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>): void => {
e.preventDefault();
e.stopPropagation();
setDragActive(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>): void => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>): void => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = async (
e: React.DragEvent<HTMLDivElement>,
): Promise<void> => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (uploading || hookLoading) return;
const file = e.dataTransfer.files?.[0];
if (!file) return;
await handleFile(file);
};
const isLoading = uploading || hookLoading;
return (
<div className="flex flex-col gap-2">
<input
ref={inputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
disabled={isLoading}
aria-label={t("ariaLabel")}
/>
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
}}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border border-dashed px-4 py-2 text-sm transition-colors",
dragActive
? "border-primary bg-primary/5 text-primary"
: "border-input bg-background hover:bg-muted/40",
isLoading && "cursor-not-allowed opacity-70",
)}
aria-label={t("dragDrop")}
>
<UploadCloud className="size-4" />
<span>{isLoading ? t("uploading") : t("dragDrop")}</span>
</div>
{isLoading ? (
<div className="flex flex-col gap-1" aria-live="polite">
<Progress value={progress} />
<span className="text-xs text-muted-foreground">
{t("progress", { percent: progress })}
{fileName ? ` · ${fileName}` : ""}
</span>
</div>
) : null}
</div>
);
}

View File

@@ -1,24 +1,11 @@
"use client";
/**
* 文件管理列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5
*
* 数据契约:
* - 列表查询 fileAttachments(filter) ❌ schema 无 → MSW 兜底(@contract-pending
* - 统计查询 fileStats ❌ schema 无 → MSW 兜底
*
* URL 状态搜索为客户端过滤mimeType 筛选可通过扩展支持)
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState + 行动按钮)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { FileText, Files } from "lucide-react";
import { useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useCallback, useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useFileAttachments, useFileStats } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
@@ -26,207 +13,362 @@ import {
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { StatCard } from "@/shared/components/ui/stat-card";
import { notify } from "@/shared/lib/notify";
import {
categorizeFileByName,
categoryToIconColor,
formatFileDate,
formatFileSize,
formatMimeType,
type FileTypeCategory,
} from "@/features/admin/files/transformations";
import { FileUploadButton } from "@/features/admin/files/file-upload-button";
import { FileBatchOperations } from "@/features/admin/files/file-batch-operations";
import { FilePreviewDialog } from "@/features/admin/files/file-preview-dialog";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { cn } from "@/shared/lib/utils";
/** 默认拉取条数FileFilter.limit */
const DEFAULT_LIMIT = 100;
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 不使用,但 StatCard 等组件为 client-only
*/
/** 文件类型筛选选项value 为空表示全部) */
const FILE_TYPE_OPTIONS: Array<{
value: string;
category: FileTypeCategory | null;
}> = [
{ value: "", category: null },
{ value: "image", category: "image" },
{ value: "document", category: "document" },
{ value: "video", category: "video" },
{ value: "audio", category: "audio" },
{ value: "other", category: "other" },
];
interface FileItem {
id: string;
name: string;
size: number;
mimeType: string;
url: string;
uploadedBy: string;
uploadedAt: string;
}
export function FilesListClient(): React.ReactElement {
const t = useTranslations("admin.files.list");
const tCommon = useTranslations("common");
const tError = useTranslations("admin.files.error");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// @contract-pendingMSW 兜底
const { data, loading, error } = useFileAttachments({
const fileType = searchParams.get("fileType") ?? "";
const updateFileType = useCallback(
(value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("fileType", value);
} else {
params.delete("fileType");
}
startTransition(() => {
router.push(`/shell/admin/files?${params.toString()}`);
});
},
[router, searchParams],
);
const { data, loading, error, refetch } = useFileAttachments({
limit: DEFAULT_LIMIT,
});
// @contract-pendingMSW 兜底
const { data: stats, loading: statsLoading } = useFileStats();
const allItems = data?.items ?? [];
const allItems: FileItem[] = data?.items ?? [];
const filteredItems = useMemo(() => {
if (!search.trim()) return allItems;
const q = search.trim().toLowerCase();
return allItems.filter((f) => f.name.toLowerCase().includes(q));
}, [allItems, search]);
let items = allItems;
if (search.trim()) {
const q = search.trim().toLowerCase();
items = items.filter((f) => f.name.toLowerCase().includes(q));
}
if (fileType) {
const targetCategory = FILE_TYPE_OPTIONS.find(
(opt) => opt.value === fileType,
)?.category;
if (targetCategory) {
items = items.filter(
(f) => categorizeFileByName(f.name) === targetCategory,
);
}
}
return items;
}, [allItems, search, fileType]);
const errorNode = 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>
<p className="mt-2 text-xs text-muted-foreground">{t("mswNotice")}</p>
</div>
) : undefined;
const allSelected =
filteredItems.length > 0 &&
filteredItems.every((item) => selectedIds.has(item.id));
const someSelected = selectedIds.size > 0 && !allSelected;
const emptyNode = (
<EmptyState
icon={Files}
title={t("emptyTitle")}
description={t("emptyDescription")}
action={{
label: t("emptyAction"),
href: "/shell/admin/files",
}}
/>
const toggleAll = useCallback((): void => {
setSelectedIds((prev) => {
if (filteredItems.length === 0) return prev;
const allIn = filteredItems.every((item) => prev.has(item.id));
const next = new Set(prev);
if (allIn) {
for (const item of filteredItems) {
next.delete(item.id);
}
} else {
for (const item of filteredItems) {
next.add(item.id);
}
}
return next;
});
}, [filteredItems]);
const toggleOne = useCallback((id: string): void => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const clearSelection = useCallback((): void => {
setSelectedIds(new Set());
}, []);
const handleUploadSuccess = useCallback((): void => {
clearSelection();
void refetch();
}, [clearSelection, refetch]);
const handleBatchDeleteSuccess = useCallback((): void => {
void refetch();
}, [refetch]);
const totalFiles = stats?.totalFiles ?? 0;
const totalSize = stats?.totalSize ?? 0;
const byTypeCount = stats?.byType ? Object.keys(stats.byType).length : 0;
const topTypes = stats?.byType
? Object.entries(stats.byType)
.sort(([, a], [, b]) => b - a)
.slice(0, 2)
.map(([type, count]) => formatMimeType(type) + ": " + count)
.join(", ")
: "--";
const filters = (
<>
<FilterSearchInput
value={search}
onChange={setSearch}
placeholder={t("searchPlaceholder")}
/>
<select
value={fileType}
onChange={(e) => updateFileType(e.target.value)}
aria-label={t("fileTypeFilter")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
>
<option value="">{t("allTypes")}</option>
<option value="image">{t("typeImage")}</option>
<option value="document">{t("typeDocument")}</option>
<option value="video">{t("typeVideo")}</option>
<option value="audio">{t("typeAudio")}</option>
<option value="other">{t("typeOther")}</option>
</select>
</>
);
const actions = <FileUploadButton onSuccess={handleUploadSuccess} />;
if (loading) {
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<Files className="size-6" />}
actions={actions}
filters={filters}
loading
>
<ListPageSkeleton />
</ListPageShell>
);
}
if (error) {
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<Files className="size-6" />}
actions={actions}
filters={filters}
errorNode={
<EmptyState title={tError("title")} description={tError("unknown")} />
}
/>
);
}
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<Files className="size-6" />}
actions={
<Button onClick={() => notify.info(t("mswNotice"))}>
{t("uploadButton")}
</Button>
}
filters={
<FilterSearchInput
placeholder={t("searchPlaceholder")}
value={search}
onChange={setSearch}
actions={actions}
filters={filters}
empty={filteredItems.length === 0}
emptyNode={
<EmptyState
title={t("emptyTitle")}
description={t("emptyDescription")}
/>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: filteredItems.length })}</span>
</div>
}
>
<div className="flex flex-col gap-6">
<FilesStatsCards stats={stats} loading={statsLoading} />
<FilesTable items={filteredItems} />
<SectionErrorBoundary title={t("sectionStats")}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title={t("statTotalFiles")}
value={totalFiles}
isLoading={statsLoading}
/>
<StatCard
title={t("statTotalSize")}
value={formatFileSize(totalSize)}
isLoading={statsLoading}
/>
<StatCard
title={t("statByType")}
value={byTypeCount}
isLoading={statsLoading}
/>
<StatCard
title={t("statTopType")}
value={topTypes}
description={t("statTopTypeHint")}
isLoading={statsLoading}
/>
</div>
</SectionErrorBoundary>
<FileBatchOperations
selectedIds={selectedIds}
allSelected={allSelected}
someSelected={someSelected}
onSelectAll={toggleAll}
onClearSelection={clearSelection}
onSuccess={handleBatchDeleteSuccess}
/>
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="w-10 px-3 py-3 text-left">
<input
type="checkbox"
checked={allSelected}
onChange={toggleAll}
className="size-4 cursor-pointer rounded border-input"
aria-label={t("selectAll")}
disabled={filteredItems.length === 0}
/>
</th>
<th className="px-3 py-3 text-left font-medium">
{t("colName")}
</th>
<th className="px-3 py-3 text-left font-medium">
{t("colSize")}
</th>
<th className="px-3 py-3 text-left font-medium">
{t("colMimeType")}
</th>
<th className="px-3 py-3 text-left font-medium">
{t("colUploadedBy")}
</th>
<th className="px-3 py-3 text-left font-medium">
{t("colUploadedAt")}
</th>
<th className="px-3 py-3 text-left font-medium">
{t("colActions")}
</th>
</tr>
</thead>
<tbody>
{filteredItems.map((file) => {
const isSelected = selectedIds.has(file.id);
return (
<tr
key={file.id}
className={
isSelected
? "border-b bg-muted/40"
: "border-b hover:bg-muted/20"
}
>
<td className="px-3 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleOne(file.id)}
className="size-4 cursor-pointer rounded border-input"
aria-label={t("selectRow")}
/>
</td>
<td className="px-3 py-3">
<div className="flex items-center gap-2">
<FileText
className={cn(
"size-4",
categoryToIconColor(categorizeFileByName(file.name)),
)}
/>
<span className="font-medium">{file.name}</span>
</div>
</td>
<td className="px-3 py-3 text-muted-foreground">
{formatFileSize(file.size)}
</td>
<td className="px-3 py-3 text-muted-foreground">
{formatMimeType(file.mimeType)}
</td>
<td className="px-3 py-3 text-muted-foreground">
{file.uploadedBy}
</td>
<td className="px-3 py-3 text-muted-foreground">
{formatFileDate(file.uploadedAt)}
</td>
<td className="px-3 py-3">
<div className="flex items-center gap-2">
<FilePreviewDialog file={file} />
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
{t("download")}
</a>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="text-sm text-muted-foreground">
{t("total", { count: filteredItems.length })}
</div>
</ListPageShell>
);
}
/**
* 统计卡片组(总文件数 + 总大小 + 类型分布)。
*/
function FilesStatsCards({
stats,
loading,
}: {
stats:
| { totalFiles: number; totalSize: number; byType: Record<string, number> }
| null
| undefined;
loading: boolean;
}): React.ReactElement {
const t = useTranslations("admin.files.list");
const totalFiles = stats?.totalFiles ?? 0;
const totalSize = stats?.totalSize ?? 0;
const byType = stats?.byType ?? {};
const typeCount = Object.keys(byType).length;
const typeBreakdown = Object.entries(byType)
.map(([type, count]) => `${formatMimeType(type)}: ${count}`)
.join("");
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatCard
title={t("statTotalFiles")}
value={totalFiles}
icon={FileText}
isLoading={loading}
/>
<StatCard
title={t("statTotalSize")}
value={formatFileSize(totalSize)}
icon={Files}
isLoading={loading}
/>
<StatCard
title={t("statByType")}
value={typeCount}
description={typeBreakdown || undefined}
icon={FileText}
isLoading={loading}
/>
</div>
);
}
/**
* 文件列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function FilesTable({
items,
}: {
items: Array<{
id: string;
name: string;
size: number;
mimeType: string;
url: string;
uploadedBy: string;
uploadedAt: string;
}>;
}): React.ReactElement {
const t = useTranslations("admin.files.list");
return (
<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("colName")}</th>
<th className="p-3 text-left font-medium">{t("colSize")}</th>
<th className="p-3 text-left font-medium">{t("colMimeType")}</th>
<th className="p-3 text-left font-medium">{t("colUploadedBy")}</th>
<th className="p-3 text-left font-medium">{t("colUploadedAt")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{item.name}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatFileSize(item.size)}
</td>
<td className="p-3 text-muted-foreground">
{formatMimeType(item.mimeType)}
</td>
<td className="p-3 text-muted-foreground">{item.uploadedBy}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatFileDate(item.uploadedAt)}
</td>
<td className="p-3">
<div className="flex items-center justify-end gap-1">
<Button asChild variant="ghost" size="sm">
<a href={item.url} target="_blank" rel="noreferrer">
{t("download")}
</a>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => notify.warning(t("mswNotice"))}
>
{t("delete")}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -83,3 +83,71 @@ export function mimeTypeToCategory(mimeType: string): string {
if (slashIndex <= 0) return "unknown";
return mimeType.slice(0, slashIndex);
}
/** 文件类型分类(按扩展名聚合,用于列表筛选) */
export type FileTypeCategory =
"image" | "document" | "video" | "audio" | "other";
const EXTENSION_TO_CATEGORY: Record<string, FileTypeCategory> = {
jpg: "image",
jpeg: "image",
png: "image",
gif: "image",
webp: "image",
svg: "image",
pdf: "document",
doc: "document",
docx: "document",
xls: "document",
xlsx: "document",
ppt: "document",
pptx: "document",
txt: "document",
md: "document",
mp4: "video",
avi: "video",
mov: "video",
wmv: "video",
flv: "video",
webm: "video",
mp3: "audio",
wav: "audio",
ogg: "audio",
flac: "audio",
aac: "audio",
};
/**
* 从文件名提取小写扩展名(不含点)。无扩展名返回空字符串。
*/
export function extractFileExtension(fileName: string): string {
if (!fileName) return "";
const dotIndex = fileName.lastIndexOf(".");
if (dotIndex <= 0 || dotIndex === fileName.length - 1) return "";
return fileName.slice(dotIndex + 1).toLowerCase();
}
/**
* 根据文件名扩展名归类到 image / document / video / audio / other。
* 用于列表"文件类型"筛选。
*/
export function categorizeFileByName(fileName: string): FileTypeCategory {
const ext = extractFileExtension(fileName);
return EXTENSION_TO_CATEGORY[ext] ?? "other";
}
/** 文件分类 → 图标颜色 Tailwind 类名(对齐 CICD 分类着色) */
export function categoryToIconColor(category: FileTypeCategory): string {
switch (category) {
case "image":
return "text-emerald-500";
case "document":
return "text-blue-500";
case "video":
return "text-purple-500";
case "audio":
return "text-amber-500";
default:
return "text-muted-foreground";
}
}

View File

@@ -0,0 +1,367 @@
"use client";
/**
* 批量生成邀请码对话框(迁移自 CICD GenerateInvitationCodesDialog
*
* 流程:
* 1. 用户填写表单(批次名、数量、角色、有效期、用途)
* 2. 提交调用 useGenerateInvitationCodes
* 3. 成功后切换到「生成结果」视图,展示成功/失败统计 + 明文邀请码 + CSV 下载
* 4. 关闭对话框时通过 onGenerated 回调通知父组件刷新
*
* 设计:
* - 使用 shadcn Dialog 组件(对齐 @/shared/components/ui/dialog
* - 单对话框内两个状态form / result避免叠加多个 Dialog
* - result 视图仅此一次展示明文,关闭后无法再获取
*
* 关联ARCHITECTURE.md §7.3 / §9.4 / §10 P5
*/
import { useState } from "react";
import { useTranslations } from "next-intl";
import {
useGenerateInvitationCodes,
type GeneratedInvitationCode,
} from "@/lib/api";
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 { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { Select } from "@/shared/components/ui/select";
import { notify } from "@/shared/lib/notify";
/** 角色选项(与 IAM 角色对齐) */
const ROLE_OPTIONS = ["teacher", "student", "parent", "admin"] as const;
/** 过期天数选项(对齐任务需求 7/30/90 天) */
const EXPIRE_DAYS_OPTIONS = [7, 30, 90] as const;
/** 默认数量 */
const DEFAULT_COUNT = "10";
/** 默认过期天数 */
const DEFAULT_EXPIRE_DAYS = 30;
/** 默认角色 */
const DEFAULT_ROLE = "teacher";
/** CSV 列分隔符 */
const CSV_LINE_SEPARATOR = "\n";
interface GenerateInvitationCodesDialogProps {
open: boolean;
onClose: () => void;
onGenerated: () => void;
}
type ViewState = "form" | "result";
/**
* 批量生成邀请码对话框。open 控制显隐onGenerated 在成功后触发父组件刷新。
*/
export function GenerateInvitationCodesDialog({
open,
onClose,
onGenerated,
}: GenerateInvitationCodesDialogProps): React.ReactElement {
const t = useTranslations("admin.invitationCodes.generateDialog");
const tCommon = useTranslations("common");
const generateMutation = useGenerateInvitationCodes();
const [view, setView] = useState<ViewState>("form");
const [batchName, setBatchName] = useState<string>("");
const [count, setCount] = useState<string>(DEFAULT_COUNT);
const [role, setRole] = useState<string>(DEFAULT_ROLE);
const [expireDays, setExpireDays] = useState<number>(DEFAULT_EXPIRE_DAYS);
const [purpose, setPurpose] = useState<string>("");
const [result, setResult] = useState<GeneratedInvitationCode[] | null>(null);
const [requestedCount, setRequestedCount] = useState<number>(0);
const resetForm = (): void => {
setBatchName("");
setCount(DEFAULT_COUNT);
setRole(DEFAULT_ROLE);
setExpireDays(DEFAULT_EXPIRE_DAYS);
setPurpose("");
setResult(null);
setView("form");
};
const handleClose = (): void => {
if (view === "result") {
onGenerated();
}
onClose();
};
const handleOpenChange = (next: boolean): void => {
if (!next) {
handleClose();
// 延迟重置以便关闭动画完成
setTimeout(() => {
resetForm();
}, 200);
}
};
const handleSubmit = async (
e: React.FormEvent<HTMLFormElement>,
): Promise<void> => {
e.preventDefault();
const countNum = Number(count);
if (!Number.isFinite(countNum) || countNum < 1 || countNum > 100) {
notify.error(t("errorCountRange"));
return;
}
setRequestedCount(countNum);
try {
const res = await generateMutation.run({
count: countNum,
role,
batchName: batchName.trim() || undefined,
expireDays,
purpose: purpose.trim() || undefined,
});
notify.success(t("success", { count: res.generated.length }));
setResult(res.generated);
setView("result");
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleCopyAll = async (): Promise<void> => {
if (!result || result.length === 0) return;
const text = result.map((c) => c.code).join(CSV_LINE_SEPARATOR);
try {
await navigator.clipboard.writeText(text);
notify.success(t("copyAll"));
} catch {
notify.error(t("copyFailed"));
}
};
const handleDownloadCsv = (): void => {
if (!result || result.length === 0) return;
const header = "code,role,expiresAt,batchName,purpose";
const rows = result.map((c) =>
[
c.code,
c.role,
c.expiresAt,
batchName.trim() || "",
purpose.trim() || "",
]
.map((field) => escapeCsvField(field))
.join(","),
);
const csv = [header, ...rows].join(CSV_LINE_SEPARATOR);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `invitation-codes-${batchName.trim() || "batch"}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const successCount = result?.length ?? 0;
const failedCount = Math.max(0, requestedCount - successCount);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
{view === "form" ? (
<>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="inv-batchName">{t("fieldBatchName")}</Label>
<Input
id="inv-batchName"
value={batchName}
onChange={(e) => setBatchName(e.target.value)}
placeholder={t("fieldBatchNamePlaceholder")}
maxLength={100}
/>
<p className="text-xs text-muted-foreground">
{t("fieldBatchNameHint")}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="inv-count">{t("fieldCount")}</Label>
<Input
id="inv-count"
type="number"
min={1}
max={100}
value={count}
onChange={(e) => setCount(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
{t("countHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="inv-role">{t("fieldRole")}</Label>
<Select
id="inv-role"
value={role}
onValueChange={setRole}
options={ROLE_OPTIONS.map((r) => ({
value: r,
label: t(`roles.${r}`),
}))}
aria-label={t("fieldRole")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="inv-expireDays">{t("fieldExpireDays")}</Label>
<Select
id="inv-expireDays"
value={String(expireDays)}
onValueChange={(v) => setExpireDays(Number(v))}
options={EXPIRE_DAYS_OPTIONS.map((d) => ({
value: String(d),
label: t("expireDaysOption", { days: d }),
}))}
aria-label={t("fieldExpireDays")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inv-purpose">{t("fieldPurpose")}</Label>
<Textarea
id="inv-purpose"
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
placeholder={t("fieldPurposePlaceholder")}
maxLength={500}
rows={2}
/>
<p className="text-xs text-muted-foreground">
{t("fieldPurposeHint")}
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={generateMutation.loading}
>
{t("cancel")}
</Button>
<Button type="submit" disabled={generateMutation.loading}>
{generateMutation.loading ? t("generating") : t("submit")}
</Button>
</DialogFooter>
</form>
</>
) : (
<>
<DialogHeader>
<DialogTitle>{t("resultTitle")}</DialogTitle>
<DialogDescription>{t("resultDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 成功/失败统计 */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md border bg-emerald-500/5 p-3 text-center">
<p className="text-xs text-muted-foreground">
{t("statsSuccess")}
</p>
<p className="text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
{successCount}
</p>
</div>
<div className="rounded-md border bg-destructive/5 p-3 text-center">
<p className="text-xs text-muted-foreground">
{t("statsFailed")}
</p>
<p className="text-2xl font-semibold text-destructive">
{failedCount}
</p>
</div>
</div>
{/* 明文邀请码列表 */}
<div className="max-h-48 space-y-2 overflow-y-auto rounded-md border p-2">
{result?.map((code, idx) => (
<div
key={idx}
className="flex items-center justify-between rounded-md bg-muted/30 px-3 py-2"
>
<code className="font-mono text-sm">{code.code}</code>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
void navigator.clipboard.writeText(code.code);
notify.success(t("copyCode"));
}}
>
{t("copyCode")}
</Button>
</div>
))}
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleCopyAll()}
>
{t("copyAll")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDownloadCsv}
>
{t("downloadCsv")}
</Button>
</div>
</div>
<DialogFooter>
<Button type="button" onClick={handleClose}>
{t("done")}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}
/**
* 转义 CSV 字段(含逗号、引号、换行时用双引号包裹并转义内部引号)。
*/
function escapeCsvField(value: string): string {
if (/[",\n]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}

View File

@@ -14,25 +14,35 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { Copy, KeyRound, RefreshCw } from "lucide-react";
import { Copy, KeyRound } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import {
useCreateInvitationCode,
useDeleteInvitationCodes,
useInvitationCodes,
useRevokeInvitationCode,
type InvitationCode,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterBar } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { StatCard } from "@/shared/components/ui/stat-card";
import { notify } from "@/shared/lib/notify";
import {
formatInvitationTimestamp,
@@ -43,16 +53,13 @@ import {
isInvitationRevocable,
roleToLabel,
} from "@/features/admin/invitation-codes/transformations";
import { GenerateInvitationCodesDialog } from "@/features/admin/invitation-codes/generate-invitation-codes-dialog";
/** 状态筛选选项(与后端 status 字段对齐) */
const STATUS_OPTIONS = ["active", "used", "expired", "revoked"] as const;
/** 角色选项(与 IAM 角色对齐 */
const ROLE_OPTIONS = ["admin", "teacher", "student", "parent"] as const;
/** 默认生成参数 */
const DEFAULT_MAX_USES = 10;
const DEFAULT_TTL_HOURS = 72;
/** 每页条数(客户端分页 */
const PAGE_SIZE = 10;
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -66,16 +73,46 @@ export function InvitationCodesListClient(): React.ReactElement {
const [, startTransition] = useTransition();
const statusFilter = searchParams.get("status") ?? "";
const pageParam = searchParams.get("page") ?? "1";
const page = Math.max(1, Number.parseInt(pageParam, 10) || 1);
// @contract-pendingMSW 兜底
const { data, loading, error } = useInvitationCodes(statusFilter || null);
const createInvitation = useCreateInvitationCode();
const { data, loading, error, refetch } = useInvitationCodes(
statusFilter || null,
);
const revokeInvitation = useRevokeInvitationCode();
const deleteCodes = useDeleteInvitationCodes();
const [showGenerateForm, setShowGenerateForm] = useState(false);
const [showGenerateDialog, setShowGenerateDialog] = useState(false);
const [pendingRevokeId, setPendingRevokeId] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// 服务端 page.tsx 注入 now避免 client 端 Date.now() 导致 hydration mismatch
// 见 ARCHITECTURE.md §11.3 DoD "纯函数不调用 Date.now()"
const now = Number(searchParams.get("now") ?? Date.now());
const items = data ?? [];
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pagedItems = items.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE,
);
// 统计:基于 effectiveStatus 前端聚合(无需新查询),依赖 now 判断过期
const stats = useMemo(() => {
let used = 0;
let unused = 0;
let expired = 0;
for (const item of items) {
const status = getEffectiveStatus(item, now);
if (status === "used") used += 1;
else if (status === "expired") expired += 1;
else if (status === "active") unused += 1;
}
return { total: items.length, used, unused, expired };
}, [items, now]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
@@ -84,6 +121,10 @@ export function InvitationCodesListClient(): React.ReactElement {
} else {
params.delete(key);
}
// 切换筛选时重置页码
if (key === "status") {
params.delete("page");
}
startTransition(() => {
router.push(`/shell/admin/invitation-codes?${params.toString()}`);
});
@@ -110,6 +151,38 @@ export function InvitationCodesListClient(): React.ReactElement {
}
};
const toggleSelect = (id: string): void => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleSelectAll = (): void => {
if (selectedIds.size === items.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(items.map((i) => i.id)));
}
};
const handleDeleteSelected = async (): Promise<void> => {
try {
await deleteCodes.run(Array.from(selectedIds));
notify.success(t("deleteSuccess"));
setSelectedIds(new Set());
setDeleteDialogOpen(false);
void refetch();
} catch (e) {
notify.error(tCommon("error.loadFailed", { message: String(e) }));
}
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
@@ -137,14 +210,26 @@ export function InvitationCodesListClient(): React.ReactElement {
description={t("description")}
icon={<KeyRound className="size-6" />}
actions={
<Button
onClick={() => setShowGenerateForm((v) => !v)}
variant="default"
size="sm"
>
<KeyRound className="size-4" />
{t("generateButton")}
</Button>
<>
<Button
onClick={() => setShowGenerateDialog(true)}
variant="default"
size="sm"
>
<KeyRound className="size-4" />
{t("generateButton")}
</Button>
{selectedIds.size > 0 ? (
<Button
onClick={() => setDeleteDialogOpen(true)}
variant="destructive"
size="sm"
disabled={deleteCodes.loading}
>
{t("deleteSelected", { count: selectedIds.size })}
</Button>
) : null}
</>
}
filters={
<FilterBar variant="wrap">
@@ -169,142 +254,97 @@ export function InvitationCodesListClient(): React.ReactElement {
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: items.length })}</span>
</div>
<PaginationBar
total={items.length}
page={currentPage}
pageSize={PAGE_SIZE}
onNavigate={(p) => updateQuery("page", String(p))}
/>
}
>
<div className="flex flex-col gap-4">
{showGenerateForm ? (
<GenerateForm
loading={createInvitation.loading}
onSubmit={async (role, maxUses, ttlHours) => {
try {
await createInvitation.run({ role, maxUses, ttlHours });
notify.success(t("generateButton"));
setShowGenerateForm(false);
} catch (e) {
notify.error(
tCommon("error.loadFailed", { message: String(e) }),
);
}
}}
onCancel={() => setShowGenerateForm(false)}
/>
) : null}
<InvitationCodesTable
items={items}
pendingRevokeId={pendingRevokeId}
onCopy={handleCopy}
onRevoke={handleRevoke}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title={t("statsTotal")}
value={stats.total}
isLoading={loading}
/>
<StatCard
title={t("statsUsed")}
value={stats.used}
isLoading={loading}
/>
<StatCard
title={t("statsUnused")}
value={stats.unused}
isLoading={loading}
/>
<StatCard
title={t("statsExpired")}
value={stats.expired}
isLoading={loading}
/>
</div>
<div className="flex flex-col gap-4">
<InvitationCodesTable
items={pagedItems}
pendingRevokeId={pendingRevokeId}
selectedIds={selectedIds}
onToggleSelect={toggleSelect}
onToggleSelectAll={toggleSelectAll}
onCopy={handleCopy}
onRevoke={handleRevoke}
now={now}
/>
</div>
<GenerateInvitationCodesDialog
open={showGenerateDialog}
onClose={() => setShowGenerateDialog(false)}
onGenerated={() => void refetch()}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteConfirmDesc", { count: selectedIds.size })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => void handleDeleteSelected()}
disabled={deleteCodes.loading}
>
{t("confirmDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ListPageShell>
);
}
/**
* 生成邀请码表单(内联展开,对齐 §7.3 列表页 + 行内操作)。
*/
function GenerateForm({
loading,
onSubmit,
onCancel,
}: {
loading: boolean;
onSubmit: (role: string, maxUses: number, ttlHours: number) => Promise<void>;
onCancel: () => void;
}): React.ReactElement {
const tForm = useTranslations("admin.invitationCodes.generateForm");
const [role, setRole] = useState<string>("teacher");
const [maxUses, setMaxUses] = useState<number>(DEFAULT_MAX_USES);
const [ttlHours, setTtlHours] = useState<number>(DEFAULT_TTL_HOURS);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault();
void onSubmit(role, maxUses, ttlHours);
};
return (
<Card>
<CardContent className="p-4">
<h3 className="mb-3 text-sm font-semibold">{tForm("title")}</h3>
<form
onSubmit={handleSubmit}
className="flex flex-wrap items-end gap-3"
>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">{tForm("fieldRole")}</span>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{roleToLabel(r)}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">
{tForm("fieldMaxUses")}
</span>
<input
type="number"
min={1}
value={maxUses}
onChange={(e) => setMaxUses(Number(e.target.value) || 1)}
className="h-9 w-24 rounded-md border border-input bg-background px-3 text-sm"
/>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">
{tForm("fieldTtlHours")}
</span>
<input
type="number"
min={1}
value={ttlHours}
onChange={(e) => setTtlHours(Number(e.target.value) || 1)}
className="h-9 w-24 rounded-md border border-input bg-background px-3 text-sm"
/>
</label>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={loading}>
<RefreshCw className="size-4" />
{tForm("submit")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onCancel}
disabled={loading}
>
{tForm("cancel")}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
/**
* 邀请码列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function InvitationCodesTable({
items,
pendingRevokeId,
selectedIds,
onToggleSelect,
onToggleSelectAll,
onCopy,
onRevoke,
now,
}: {
items: InvitationCode[];
pendingRevokeId: string | null;
selectedIds: Set<string>;
onToggleSelect: (id: string) => void;
onToggleSelectAll: () => void;
onCopy: (code: string) => void;
onRevoke: (id: string) => void;
now: number;
}): React.ReactElement {
const t = useTranslations("admin.invitationCodes.list");
return (
@@ -312,6 +352,15 @@ function InvitationCodesTable({
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left">
<input
type="checkbox"
checked={items.length > 0 && selectedIds.size === items.length}
onChange={onToggleSelectAll}
aria-label="select all"
className="size-4 rounded border-input"
/>
</th>
<th className="p-3 text-left font-medium">{t("colCode")}</th>
<th className="p-3 text-left font-medium">{t("colRole")}</th>
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
@@ -324,10 +373,19 @@ function InvitationCodesTable({
</thead>
<tbody className="divide-y">
{items.map((item) => {
const effectiveStatus = getEffectiveStatus(item);
const canRevoke = isInvitationRevocable(item);
const effectiveStatus = getEffectiveStatus(item, now);
const canRevoke = isInvitationRevocable(item, now);
return (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-3">
<input
type="checkbox"
checked={selectedIds.has(item.id)}
onChange={() => onToggleSelect(item.id)}
aria-label="select"
className="size-4 rounded border-input"
/>
</td>
<td className="p-3 font-mono text-xs">{item.code}</td>
<td className="p-3 text-muted-foreground">
{roleToLabel(item.role)}
@@ -391,3 +449,92 @@ function StatusBadge({ status }: { status: string }): React.ReactElement {
</span>
);
}
/**
* 分页条(页码按钮 + 上一页/下一页 + 总数)。
* 总页数 > 7 时使用窗口策略(首末页 + 当前页 ±1 + 省略号)。
*/
function PaginationBar({
total,
page,
pageSize,
onNavigate,
}: {
total: number;
page: number;
pageSize: number;
onNavigate: (page: number) => void;
}): React.ReactElement {
const t = useTranslations("admin.invitationCodes.list");
const tCommon = useTranslations("common");
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(Math.max(1, page), totalPages);
const pages: Array<number | "ellipsis"> = (() => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const result: Array<number | "ellipsis"> = [1];
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
if (start > 2) result.push("ellipsis");
for (let i = start; i <= end; i++) result.push(i);
if (end < totalPages - 1) result.push("ellipsis");
result.push(totalPages);
return result;
})();
if (total === 0) {
return (
<div className="flex items-center justify-end text-sm text-muted-foreground">
<span>{t("total", { count: 0 })}</span>
</div>
);
}
return (
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: total })}</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => onNavigate(currentPage - 1)}
>
{tCommon("prev")}
</Button>
{pages.map((p, idx) =>
p === "ellipsis" ? (
<span
key={`ellipsis-${idx}`}
className="px-2 text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === currentPage ? "default" : "outline"}
size="sm"
disabled={p === currentPage}
onClick={() => onNavigate(p)}
aria-current={p === currentPage ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => onNavigate(currentPage + 1)}
>
{tCommon("next")}
</Button>
</div>
</div>
);
}

View File

@@ -92,22 +92,36 @@ export function invitationStatusToBadgeClass(status: string): string {
/**
* 判断邀请码是否已过期(基于 expiresAt 与当前时间比较)。
* 已撤销或已用完的码不算过期。
*
* @param code 邀请码对象
* @param now 当前时间戳ms由调用方注入以避免纯函数内调用 Date.now()
* 避免 SSR/CSR 时间漂移导致 hydration mismatch。
* 默认值 Date.now() 仅为兼容旧调用方,新代码应显式注入。
*/
export function isInvitationExpired(code: InvitationCode): boolean {
export function isInvitationExpired(
code: InvitationCode,
now: number = Date.now(),
): boolean {
if (code.status === "expired") return true;
if (code.status === "used" || code.status === "revoked") return false;
if (!code.expiresAt) return false;
const d = new Date(code.expiresAt);
if (Number.isNaN(d.getTime())) return false;
return d.getTime() < Date.now();
return d.getTime() < now;
}
/**
* 获取邀请码的有效显示状态。
* 优先返回数据库 status若数据库为 active 但已过期则返回 "expired"。
*
* @param code 邀请码对象
* @param now 当前时间戳ms由调用方注入推荐从 server page.tsx 传入避免 client Date.now
*/
export function getEffectiveStatus(code: InvitationCode): string {
if (code.status === "active" && isInvitationExpired(code)) {
export function getEffectiveStatus(
code: InvitationCode,
now: number = Date.now(),
): string {
if (code.status === "active" && isInvitationExpired(code, now)) {
return "expired";
}
return code.status;
@@ -135,9 +149,15 @@ export function isInvitationUsedUp(code: InvitationCode): boolean {
/**
* 判断邀请码是否可撤销(仅 active 状态可撤销)。
*
* @param code 邀请码对象
* @param now 当前时间戳ms由调用方注入推荐从 server page.tsx 传入避免 client Date.now
*/
export function isInvitationRevocable(code: InvitationCode): boolean {
return getEffectiveStatus(code) === "active";
export function isInvitationRevocable(
code: InvitationCode,
now: number = Date.now(),
): boolean {
return getEffectiveStatus(code, now) === "active";
}
// ============================================================

View File

@@ -5,130 +5,35 @@
*/
import { describe, expect, it } from "vitest";
import {
LESSON_PLAN_STATUS_LABEL,
formatLessonPlanDate,
formatLessonPlanStatus,
isLessonPlanArchived,
isLessonPlanEditable,
isLessonPlanPublished,
lessonPlanStatusToBadgeClass,
toAdminLessonPlanListItem,
} from "../transformations";
import { formatCount } from "../transformations";
describe("formatLessonPlanStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatLessonPlanStatus("DRAFT")).toBe("草稿");
expect(formatLessonPlanStatus("PUBLISHED")).toBe("已发布");
expect(formatLessonPlanStatus("ARCHIVED")).toBe("已归档");
expect(formatLessonPlanStatus("SUBMITTED")).toBe("已提交");
describe("formatCount", () => {
it("returns string representation for non-negative finite numbers", () => {
expect(formatCount(0)).toBe("0");
expect(formatCount(1)).toBe("1");
expect(formatCount(42)).toBe("42");
expect(formatCount(1000)).toBe("1000");
});
it("returns original value for unknown status", () => {
expect(formatLessonPlanStatus("UNKNOWN")).toBe("UNKNOWN");
expect(formatLessonPlanStatus("")).toBe("");
it("returns '0' for null", () => {
expect(formatCount(null)).toBe("0");
});
it("LESSON_PLAN_STATUS_LABEL covers 4 standard statuses", () => {
expect(Object.keys(LESSON_PLAN_STATUS_LABEL)).toHaveLength(4);
});
});
describe("formatLessonPlanDate", () => {
it("formats valid ISO date string", () => {
const result = formatLessonPlanDate("2026-07-22T10:30:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatLessonPlanDate(null)).toBe("--");
expect(formatLessonPlanDate(undefined)).toBe("--");
expect(formatLessonPlanDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatLessonPlanDate("not-a-date")).toBe("--");
});
});
describe("isLessonPlanEditable", () => {
it("returns true for DRAFT and PUBLISHED", () => {
expect(isLessonPlanEditable("DRAFT")).toBe(true);
expect(isLessonPlanEditable("PUBLISHED")).toBe(true);
});
it("returns false for ARCHIVED and unknown", () => {
expect(isLessonPlanEditable("ARCHIVED")).toBe(false);
expect(isLessonPlanEditable("UNKNOWN")).toBe(false);
});
});
describe("isLessonPlanPublished", () => {
it("returns true only for PUBLISHED", () => {
expect(isLessonPlanPublished("PUBLISHED")).toBe(true);
expect(isLessonPlanPublished("DRAFT")).toBe(false);
expect(isLessonPlanPublished("ARCHIVED")).toBe(false);
});
});
describe("isLessonPlanArchived", () => {
it("returns true only for ARCHIVED", () => {
expect(isLessonPlanArchived("ARCHIVED")).toBe(true);
expect(isLessonPlanArchived("PUBLISHED")).toBe(false);
expect(isLessonPlanArchived("DRAFT")).toBe(false);
});
});
describe("toAdminLessonPlanListItem", () => {
it("extracts list fields from full detail and drops extra fields", () => {
const detail = {
id: "lp-001",
title: "集合的概念",
subjectId: "sub-math",
subjectName: "数学",
teacherId: "usr-001",
teacherName: "张老师",
classId: "cls-001",
className: "高三(1)班",
status: "PUBLISHED",
createdAt: "2026-07-10T00:00:00Z",
updatedAt: "2026-07-15T00:00:00Z",
};
const item = toAdminLessonPlanListItem(detail);
expect(item.id).toBe("lp-001");
expect(item.title).toBe("集合的概念");
expect(item.subjectName).toBe("数学");
expect(item.teacherName).toBe("张老师");
expect(item.className).toBe("高三(1)班");
expect(item.status).toBe("PUBLISHED");
expect(item).not.toHaveProperty("textbookId");
expect(item).not.toHaveProperty("content");
});
});
describe("lessonPlanStatusToBadgeClass", () => {
it("returns primary class for PUBLISHED", () => {
expect(lessonPlanStatusToBadgeClass("PUBLISHED")).toContain("primary");
});
it("returns amber class for SUBMITTED", () => {
expect(lessonPlanStatusToBadgeClass("SUBMITTED")).toContain("amber");
});
it("returns muted class for DRAFT and ARCHIVED", () => {
expect(lessonPlanStatusToBadgeClass("DRAFT")).toBe(
"bg-muted text-muted-foreground",
);
expect(lessonPlanStatusToBadgeClass("ARCHIVED")).toBe(
"bg-muted text-muted-foreground",
);
});
it("returns muted class for unknown status", () => {
expect(lessonPlanStatusToBadgeClass("UNKNOWN")).toBe(
"bg-muted text-muted-foreground",
);
it("returns '0' for undefined", () => {
expect(formatCount(undefined)).toBe("0");
});
it("returns '0' for NaN", () => {
expect(formatCount(Number.NaN)).toBe("0");
});
it("returns '0' for Infinity", () => {
expect(formatCount(Number.POSITIVE_INFINITY)).toBe("0");
expect(formatCount(Number.NEGATIVE_INFINITY)).toBe("0");
});
it("returns '0' for negative numbers", () => {
expect(formatCount(-1)).toBe("0");
expect(formatCount(-100)).toBe("0");
});
});

View File

@@ -0,0 +1,106 @@
"use client";
/**
* 教案软删除确认对话框轻量自实现模态ARCHITECTURE.md §7.3 / §9.4
*
* 用于 admin/lesson-plans 列表页与详情页的删除确认。
* 结构fixed inset-0 + bg-black/50 + 居中卡片。
* 交互ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体。
*
* 关联ARCHITECTURE.md §7.3 详情/列表页 / §9.4 / §11.3
*/
import { AlertTriangle, Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
export interface DeleteConfirmDialogProps {
/** 是否打开 */
open: boolean;
/** 确认删除回调 */
onConfirm: () => void;
/** 取消回调(点击遮罩 / ESC / 取消按钮) */
onCancel: () => void;
/** 删除进行中(禁用按钮、隐藏 spinner */
loading?: boolean;
}
/**
* 教案软删除确认对话框。
*
* 文案来自 admin.lessonPlans.delete.* 命名空间。
*/
export function DeleteConfirmDialog({
open,
onConfirm,
onCancel,
loading = false,
}: DeleteConfirmDialogProps): React.ReactElement | null {
const t = useTranslations("admin.lessonPlans.delete");
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent): void => {
if (e.key === "Escape" && !loading) {
onCancel();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, loading, onCancel]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={loading ? undefined : onCancel}
role="dialog"
aria-modal="true"
aria-label={t("title")}
>
<div
className="w-full max-w-md rounded-xl border bg-card p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</span>
<div className="flex-1">
<h2 className="text-lg font-semibold">{t("title")}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("description")}
</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
{t("cancel")}
</Button>
<Button
type="button"
variant="destructive"
onClick={onConfirm}
disabled={loading}
>
{loading ? (
<>
<Loader2 className="size-4 animate-spin" />
{t("confirm")}
</>
) : (
t("confirm")
)}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -13,21 +13,25 @@
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
*/
import { BookOpen } from "lucide-react";
import { useParams } from "next/navigation";
import { BookOpen, Trash2 } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import {
useAdminLessonPlan,
useSoftDeleteLessonPlan,
type AdminLessonPlan as AdminLessonPlanData,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import { DeleteConfirmDialog } from "@/features/admin/lesson-plans/delete-confirm-dialog";
import { notify } from "@/shared/lib/notify";
import {
formatLessonPlanDate,
@@ -41,18 +45,47 @@ import {
export function AdminLessonPlanViewClient(): React.ReactElement {
const t = useTranslations("admin.lessonPlans.detail");
const tCommon = useTranslations("common");
const tDelete = useTranslations("admin.lessonPlans.delete");
const router = useRouter();
const params = useParams<{ planId: string }>();
const planId = params?.planId ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useAdminLessonPlan(planId);
// 软删除 mutation@contract-pendingMSW 兜底)
const { run: runSoftDelete, loading: deleteLoading } =
useSoftDeleteLessonPlan();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
useEffect(() => {
if (error) {
notify.error(tCommon("error.loadFailed", { message: String(error) }));
}
}, [error, tCommon]);
const handleDeleteClick = (): void => {
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async (): Promise<void> => {
if (!planId) return;
try {
await runSoftDelete(planId);
notify.success(tDelete("success"));
setDeleteDialogOpen(false);
// 删除成功后返回列表页
router.push("/shell/admin/lesson-plans");
} catch {
notify.error(tDelete("error"));
}
};
const handleDeleteCancel = (): void => {
if (deleteLoading) return;
setDeleteDialogOpen(false);
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
@@ -68,18 +101,47 @@ export function AdminLessonPlanViewClient(): React.ReactElement {
</div>
) : undefined;
const isArchived = data?.status === "ARCHIVED";
const actions = (
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/shell/admin/lesson-plans">{t("backToList")}</Link>
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDeleteClick}
disabled={isArchived || deleteLoading || loading || !data}
aria-label={tDelete("button")}
>
<Trash2 className="size-4" />
{tDelete("button")}
</Button>
</div>
);
return (
<DetailPageShell
title={data?.title ?? t("title")}
icon={<BookOpen className="size-6" />}
backHref="/shell/admin/lesson-plans"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <AdminLessonPlanViewBody plan={data} /> : null}
</DetailPageShell>
<>
<DetailPageShell
title={data?.title ?? t("title")}
icon={<BookOpen className="size-6" />}
backHref="/shell/admin/lesson-plans"
actions={actions}
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <AdminLessonPlanViewBody plan={data} /> : null}
</DetailPageShell>
<DeleteConfirmDialog
open={deleteDialogOpen}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
loading={deleteLoading}
/>
</>
);
}

View File

@@ -13,21 +13,29 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { BookOpen } from "lucide-react";
import { Archive, BookOpen, CheckCircle, FileEdit, Trash2 } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useAdminLessonPlans, type AdminLessonPlanListItem } from "@/lib/api";
import {
useAdminLessonPlans,
useSoftDeleteLessonPlan,
type AdminLessonPlanListItem,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { DeleteConfirmDialog } from "@/features/admin/lesson-plans/delete-confirm-dialog";
import { notify } from "@/shared/lib/notify";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { StatCard } from "@/shared/components/ui/stat-card";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatCount,
formatLessonPlanDate,
formatLessonPlanStatus,
lessonPlanStatusToBadgeClass,
@@ -37,6 +45,11 @@ import {
const STATUS_OPTIONS = ["DRAFT", "PUBLISHED", "ARCHIVED", "SUBMITTED"] as const;
type StatusOption = (typeof STATUS_OPTIONS)[number];
/** 每页条数 */
const PAGE_SIZE = 10;
/** 分页组件最多展示的页码按钮数(奇数,便于左右对称) */
const MAX_PAGE_BUTTONS = 7;
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
@@ -53,10 +66,18 @@ export function AdminLessonPlansListClient(): React.ReactElement {
? (statusParam as StatusOption)
: "";
const q = searchParams.get("q") ?? "";
const page = Number(searchParams.get("page") ?? "1") || 1;
// @contract-pendingMSW 兜底
const { data, loading, error } = useAdminLessonPlans();
// 软删除 mutation@contract-pendingMSW 兜底)
const { run: runSoftDelete, loading: deleteLoading } =
useSoftDeleteLessonPlan();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const tDelete = useTranslations("admin.lessonPlans.delete");
// 客户端二次筛选status + q—— 后端补齐列表查询后改服务端筛选
const filteredItems = useMemo<AdminLessonPlanListItem[]>(() => {
const items = data?.items ?? [];
@@ -71,6 +92,39 @@ export function AdminLessonPlansListClient(): React.ReactElement {
});
}, [data, status, q]);
const total = filteredItems.length;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(Math.max(1, page), totalPages);
const pagedItems = useMemo<AdminLessonPlanListItem[]>(() => {
const start = (safePage - 1) * PAGE_SIZE;
return filteredItems.slice(start, start + PAGE_SIZE);
}, [filteredItems, safePage]);
const handleDeleteClick = (planId: string): void => {
setPendingDeleteId(planId);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async (): Promise<void> => {
if (!pendingDeleteId) return;
try {
await runSoftDelete(pendingDeleteId);
notify.success(tDelete("success"));
setDeleteDialogOpen(false);
setPendingDeleteId(null);
// 刷新列表MSW 已就地修改 mock 数据,重新拉取即可反映状态变化)
router.refresh();
} catch {
notify.error(tDelete("error"));
}
};
const handleDeleteCancel = (): void => {
if (deleteLoading) return;
setDeleteDialogOpen(false);
setPendingDeleteId(null);
};
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
@@ -78,8 +132,8 @@ export function AdminLessonPlansListClient(): React.ReactElement {
} else {
params.delete(key);
}
// 切换筛选时重置页码(暂无分页,保留兼容入口)
if (key === "status") {
// 切换筛选时重置页码
if (key !== "page") {
params.delete("page");
}
startTransition(() => {
@@ -109,49 +163,121 @@ export function AdminLessonPlansListClient(): React.ReactElement {
);
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<BookOpen className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/admin/lesson-plans">{t("createButton")}</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
<>
<ListPageShell
title={t("title")}
description={t("description")}
icon={<BookOpen className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/admin/lesson-plans">{t("createButton")}</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<select
value={status}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("statusFilter")}
>
<option value="">{t("allStatuses")}</option>
<option value="DRAFT">{t("statusDraft")}</option>
<option value="PUBLISHED">{t("statusPublished")}</option>
<option value="ARCHIVED">{t("statusArchived")}</option>
<option value="SUBMITTED">{t("statusSubmitted")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<Pagination
page={safePage}
pageSize={PAGE_SIZE}
total={total}
onJump={(p) => updateQuery("page", String(p))}
/>
}
>
<div className="flex flex-col gap-6">
<LessonPlansStatsCards items={filteredItems} loading={loading} />
<AdminLessonPlansTable
items={pagedItems}
onDelete={handleDeleteClick}
/>
<select
value={status}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("statusFilter")}
>
<option value="">{t("allStatuses")}</option>
<option value="DRAFT">{t("statusDraft")}</option>
<option value="PUBLISHED">{t("statusPublished")}</option>
<option value="ARCHIVED">{t("statusArchived")}</option>
<option value="SUBMITTED">{t("statusSubmitted")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: filteredItems.length })}</span>
</div>
}
>
<AdminLessonPlansTable items={filteredItems} />
</ListPageShell>
</ListPageShell>
<DeleteConfirmDialog
open={deleteDialogOpen}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
loading={deleteLoading}
/>
</>
);
}
/**
* 教案统计概览卡片组(总数 / 已发布 / 草稿 / 已归档)。
*
* 对齐 CICD admin/lesson-plans/page.tsx 的"4 卡"契约。
* 三态loading 时 StatCard isLoading=true 显示骨架。
*/
function LessonPlansStatsCards({
items,
loading,
}: {
items: AdminLessonPlanListItem[];
loading: boolean;
}): React.ReactElement {
const t = useTranslations("admin.lessonPlans.list");
const total = items.length;
const published = items.filter((i) => i.status === "PUBLISHED").length;
const draft = items.filter((i) => i.status === "DRAFT").length;
const archived = items.filter((i) => i.status === "ARCHIVED").length;
return (
<section aria-label={t("statsTitle")}>
<h3 className="mb-3 text-sm font-medium text-muted-foreground">
{t("statsTitle")}
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title={t("statsTotal")}
value={formatCount(total)}
icon={BookOpen}
isLoading={loading}
/>
<StatCard
title={t("statsPublished")}
value={formatCount(published)}
icon={CheckCircle}
isLoading={loading}
/>
<StatCard
title={t("statsDraft")}
value={formatCount(draft)}
icon={FileEdit}
isLoading={loading}
/>
<StatCard
title={t("statsArchived")}
value={formatCount(archived)}
icon={Archive}
isLoading={loading}
/>
</div>
</section>
);
}
@@ -160,10 +286,13 @@ export function AdminLessonPlansListClient(): React.ReactElement {
*/
function AdminLessonPlansTable({
items,
onDelete,
}: {
items: AdminLessonPlanListItem[];
onDelete: (planId: string) => void;
}): React.ReactElement {
const t = useTranslations("admin.lessonPlans.list");
const tDelete = useTranslations("admin.lessonPlans.delete");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
@@ -201,12 +330,25 @@ function AdminLessonPlansTable({
{formatLessonPlanDate(plan.updatedAt)}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/admin/lesson-plans/${plan.id}/view`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("viewDetail")}
</Link>
<div className="flex items-center justify-end gap-1">
<Link
href={`/shell/admin/lesson-plans/${plan.id}/view`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("viewDetail")}
</Link>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onDelete(plan.id)}
disabled={plan.status === "ARCHIVED"}
aria-label={tDelete("button")}
title={tDelete("button")}
>
<Trash2 className="size-4" />
</Button>
</div>
</td>
</tr>
))}
@@ -234,3 +376,111 @@ function LessonPlanStatusBadge({
</span>
);
}
/**
* 分页组件(页码列表 + 跳转按钮 + total/totalPages 显示)。
* 依赖 URL ?page=N 状态,由父组件控制路由跳转。
* 页码按钮策略:当 totalPages ≤ MAX_PAGE_BUTTONS 时全量展示;
* 超过时展示首尾页 + 当前页附近的页码(含省略号占位)。
*/
function Pagination({
page,
pageSize,
total,
onJump,
}: {
page: number;
pageSize: number;
total: number;
onJump: (page: number) => void;
}): React.ReactElement {
const t = useTranslations("admin.lessonPlans.list");
const tCommon = useTranslations("common");
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const canPrev = page > 1;
const canNext = page < totalPages;
const pages = buildPageList(page, totalPages, MAX_PAGE_BUTTONS);
return (
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span>{t("total", { count: total })}</span>
<span className="text-xs">
{page} / {totalPages}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onJump(Math.max(1, page - 1))}
disabled={!canPrev}
aria-label={tCommon("button.prev")}
>
{tCommon("button.prev")}
</Button>
{pages.map((p, idx) =>
p === "..." ? (
<span
key={`gap-${idx}`}
className="px-2 text-xs text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="sm"
onClick={() => onJump(p)}
aria-current={p === page ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="sm"
onClick={() => onJump(Math.min(totalPages, page + 1))}
disabled={!canNext}
aria-label={tCommon("button.next")}
>
{tCommon("button.next")}
</Button>
</div>
</div>
);
}
/**
* 构造页码列表:当总页数不超过 maxButtons 时全部展示;
* 否则展示首尾页与当前页附近页码,省略位置用 "..." 占位。
*/
function buildPageList(
current: number,
total: number,
maxButtons: number,
): Array<number | "..."> {
if (total <= maxButtons) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const half = Math.floor(maxButtons / 2);
const start = Math.max(2, current - half + 1);
const end = Math.min(total - 1, start + maxButtons - 4);
const adjustedStart =
end - start < maxButtons - 4 ? Math.max(2, end - (maxButtons - 5)) : start;
const result: Array<number | "..."> = [1];
if (adjustedStart > 2) {
result.push("...");
}
for (let p = adjustedStart; p <= end; p += 1) {
result.push(p);
}
if (end < total - 1) {
result.push("...");
}
result.push(total);
return result;
}

View File

@@ -96,6 +96,16 @@ export function toAdminLessonPlanListItem(detail: {
};
}
/**
* 格式化数量为展示字符串。
* 输入无效null/undefined/NaN/负数)返回 "0"。
*/
export function formatCount(count: number | null | undefined): string {
if (count === null || count === undefined) return "0";
if (!Number.isFinite(count) || count < 0) return "0";
return `${count}`;
}
/**
* 根据教案状态返回 Tailwind 徽章语义类名。
*/

View File

@@ -11,17 +11,18 @@
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { Building2 } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useOrganizationTree } from "@/lib/api/admin-p5";
import type { OrgNode } from "@/lib/api/admin-p5";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
collectNodeIds,
formatMemberCount,
@@ -205,12 +206,13 @@ function OrgNodeRow({
</td>
<td className="p-3 text-muted-foreground">{node.children.length}</td>
<td className="p-3">
<Link
href={`/shell/admin/organization/${node.id}`}
className="text-sm text-primary hover:underline"
<Button
variant="ghost"
size="sm"
onClick={() => notify.info(t("list.mswNotice"))}
>
{t("list.viewDetail")}
</Link>
</Button>
</td>
</tr>
{!isCollapsed &&

View File

@@ -156,6 +156,9 @@ function PermissionsGrouped({
<th className="p-3 text-left font-medium">
{t("list.colPermission")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colValue")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colAction")}
</th>
@@ -173,6 +176,9 @@ function PermissionsGrouped({
className="hover:bg-muted/30"
>
<td className="p-3 font-medium">{perm.name}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{perm.value ?? "--"}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{perm.action}
</td>

View File

@@ -0,0 +1,168 @@
"use client";
/**
* 题库批量操作工具栏(迁移自 CICD questions 模块)
*
* 流程:
* 1. 父组件维护 selectedIds 集合,传入本组件
* 2. 当 selectedIds 非空时,本组件展示工具栏(含计数 + 批量删除按钮)
* 3. 用户点击「批量删除」→ 弹出确认对话框 → 调用 useBatchDeleteQuestions
* 4. 成功后通知 + 触发父组件刷新 + 清空选中
*
* 设计:
* - 轻量模态确认(对齐 create-question-dialog.tsx 的轻量模式)
* - 工具栏样式对齐 ListPageShell actions 区
*
* 数据契约:
* - batchDeleteQuestions(ids) ❌ schema 无 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#batch-delete-questions-mutation
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.4 / §10 P5 / §11.4
*/
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useBatchDeleteQuestions } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { notify } from "@/shared/lib/notify";
interface BatchOperationsProps {
selectedIds: string[];
onClear: () => void;
onDeleted: () => void;
}
/**
* 批量操作工具栏。
*
* - selectedIds 为空时不渲染
* - 非空时展示:选中数量 + 批量删除按钮 + 清空选择按钮
* - 点击批量删除弹出确认模态
*
* @param selectedIds 当前选中的题目 id 列表
* @param onClear 清空选中(父组件清空 selectedIds
* @param onDeleted 删除成功后的回调(父组件刷新列表 + 清空选中)
*/
export function BatchOperations({
selectedIds,
onClear,
onDeleted,
}: BatchOperationsProps): React.ReactElement | null {
const t = useTranslations("admin.questions.batch");
const tCommon = useTranslations("common");
const deleteMutation = useBatchDeleteQuestions();
const [confirmOpen, setConfirmOpen] = useState(false);
if (selectedIds.length === 0) return null;
const handleDeleteClick = (): void => {
setConfirmOpen(true);
};
const handleConfirmDelete = async (): Promise<void> => {
try {
const result = await deleteMutation.run(selectedIds);
notify.success(
t("deleteSuccess", { deleted: result.deleted, failed: result.failed }),
);
onDeleted();
setConfirmOpen(false);
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleCancelDelete = (): void => {
setConfirmOpen(false);
};
return (
<>
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-4 py-2 text-sm">
<span className="font-medium">
{t("selectedCount", { count: selectedIds.length })}
</span>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onClear}
disabled={deleteMutation.loading}
>
{t("clearSelection")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleDeleteClick}
disabled={deleteMutation.loading}
>
{t("batchDelete")}
</Button>
</div>
</div>
{confirmOpen ? (
<ConfirmDeleteDialog
count={selectedIds.length}
loading={deleteMutation.loading}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
) : null}
</>
);
}
/**
* 批量删除确认对话框(轻量模态,对齐 create-question-dialog 的轻量模式)。
*/
function ConfirmDeleteDialog({
count,
loading,
onConfirm,
onCancel,
}: {
count: number;
loading: boolean;
onConfirm: () => void;
onCancel: () => void;
}): React.ReactElement {
const t = useTranslations("admin.questions.batch");
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onCancel}
>
<div
className="w-full max-w-md rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-2 text-lg font-semibold">{t("confirmTitle")}</h2>
<p className="mb-4 text-sm text-muted-foreground">
{t("confirmDescription", { count })}
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
{t("confirmCancel")}
</Button>
<Button
type="button"
variant="destructive"
onClick={onConfirm}
disabled={loading}
>
{loading ? t("confirming") : t("confirmSubmit")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,266 @@
"use client";
/**
* 创建题目对话框(迁移自 CICD questions 模块)
*
* 流程:
* 1. 用户填写表单(题型、内容、答案、解析、难度、知识点)
* 2. 提交调用 useCreateQuestion
* 3. 成功后通知 + 触发父组件刷新 + 关闭对话框
*
* 设计:
* - 轻量模态portal-shell 自实现,无 radix Dialog 依赖)
* - 对齐 generate-invitation-codes-dialog.tsx 的 FormField / 轻量模态模式
*
* 数据契约:
* - createQuestion(input) ❌ schema 无 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#create-question-mutation
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.4 / §10 P5 / §11.4
*/
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useCreateQuestion } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { notify } from "@/shared/lib/notify";
/** 题型选项(与 schema Question.type 字符串语义对齐) */
const TYPE_OPTIONS = [
"single_choice",
"multiple_choice",
"fill_blank",
"short_answer",
"essay",
"true_false",
] as const;
/** 难度选项(数值与 schema Question.difficulty Float 对齐0~1 */
const DIFFICULTY_OPTIONS = [
{ value: 0.3, labelKey: "difficultyEasy" },
{ value: 0.5, labelKey: "difficultyMedium" },
{ value: 0.8, labelKey: "difficultyHard" },
] as const;
const DEFAULT_TYPE = "single_choice";
const DEFAULT_DIFFICULTY = 0.5;
interface CreateQuestionDialogProps {
open: boolean;
onClose: () => void;
onCreated: () => void;
}
/**
* 创建题目对话框。open 控制显隐onCreated 在成功后触发父组件刷新。
*/
export function CreateQuestionDialog({
open,
onClose,
onCreated,
}: CreateQuestionDialogProps): React.ReactElement | null {
const t = useTranslations("admin.questions.createDialog");
const tCommon = useTranslations("common");
const createMutation = useCreateQuestion();
const [type, setType] = useState<string>(DEFAULT_TYPE);
const [content, setContent] = useState<string>("");
const [answer, setAnswer] = useState<string>("");
const [explanation, setExplanation] = useState<string>("");
const [difficulty, setDifficulty] = useState<number>(DEFAULT_DIFFICULTY);
const [knowledgePointId, setKnowledgePointId] = useState<string>("");
const [source, setSource] = useState<string>("");
if (!open) return null;
const resetForm = (): void => {
setType(DEFAULT_TYPE);
setContent("");
setAnswer("");
setExplanation("");
setDifficulty(DEFAULT_DIFFICULTY);
setKnowledgePointId("");
setSource("");
};
const handleClose = (): void => {
onClose();
};
const handleSubmit = async (
e: React.FormEvent<HTMLFormElement>,
): Promise<void> => {
e.preventDefault();
const trimmedContent = content.trim();
const trimmedAnswer = answer.trim();
if (!trimmedContent) {
notify.error(t("errorContentRequired"));
return;
}
if (!trimmedAnswer) {
notify.error(t("errorAnswerRequired"));
return;
}
if (!knowledgePointId.trim()) {
notify.error(t("errorKnowledgePointRequired"));
return;
}
try {
await createMutation.run({
type,
content: trimmedContent,
answer: trimmedAnswer,
explanation: explanation.trim() || undefined,
difficulty,
knowledgePointId: knowledgePointId.trim(),
source: source.trim() || undefined,
});
notify.success(t("success"));
onCreated();
resetForm();
onClose();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={handleClose}
>
<div
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-2 text-lg font-semibold">{t("title")}</h2>
<p className="mb-4 text-sm text-muted-foreground">{t("description")}</p>
<form onSubmit={handleSubmit} className="space-y-4">
<FormField label={t("fieldType")} required>
<select
value={type}
onChange={(e) => setType(e.target.value)}
aria-label={t("fieldType")}
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
{TYPE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{t(`types.${opt}`)}
</option>
))}
</select>
</FormField>
<FormField label={t("fieldContent")} required>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
maxLength={2000}
rows={4}
required
aria-label={t("fieldContent")}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</FormField>
<FormField label={t("fieldAnswer")} required>
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
maxLength={1000}
rows={2}
required
aria-label={t("fieldAnswer")}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</FormField>
<FormField label={t("fieldExplanation")}>
<textarea
value={explanation}
onChange={(e) => setExplanation(e.target.value)}
maxLength={1000}
rows={2}
aria-label={t("fieldExplanation")}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</FormField>
<div className="grid grid-cols-2 gap-4">
<FormField label={t("fieldDifficulty")} required>
<select
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
aria-label={t("fieldDifficulty")}
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
{DIFFICULTY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</select>
</FormField>
<FormField label={t("fieldKnowledgePoint")} required>
<Input
type="text"
value={knowledgePointId}
onChange={(e) => setKnowledgePointId(e.target.value)}
placeholder={t("fieldKnowledgePointPlaceholder")}
required
/>
</FormField>
</div>
<FormField label={t("fieldSource")}>
<Input
type="text"
value={source}
onChange={(e) => setSource(e.target.value)}
placeholder={t("fieldSourcePlaceholder")}
/>
</FormField>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={createMutation.loading}
>
{t("cancel")}
</Button>
<Button type="submit" disabled={createMutation.loading}>
{createMutation.loading ? t("submitting") : t("submit")}
</Button>
</div>
</form>
</div>
</div>
);
}
/**
* 表单字段容器label + children。对齐 generate-invitation-codes-dialog.tsx 的 FormField 模式。
*/
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}): React.ReactElement {
return (
<div className="space-y-2">
<label className="text-sm font-medium">
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</label>
{children}
</div>
);
}

View File

@@ -0,0 +1,214 @@
"use client";
/**
* 题库导入/导出按钮(迁移自 CICD questions 模块)
*
* 流程:
* - 导入:选择 CSV/JSON 文件 → 读取文件内容 → 调用 useImportQuestions → 展示结果
* - 导出:调用 useExportQuestions 触发 refetch → 将 items 转为 CSV 下载
*
* 数据契约:
* - importQuestions(input) ❌ schema 无 → MSW 兜底(@contract-pending
* - exportQuestions(filter) ❌ schema 无 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.4 / §10 P5 / §11.4
*/
import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import {
useExportQuestions,
useImportQuestions,
type ExportQuestionItem,
type QuestionsListFilter,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { notify } from "@/shared/lib/notify";
interface ImportExportButtonsProps {
filter: QuestionsListFilter;
onImported: () => void;
}
/**
* 导入/导出按钮组。
*
* - 导入按钮:触发隐藏 file input选择文件后调用 mutation
* - 导出按钮:触发查询 refetch拿到结果后下载 CSV
*
* @param filter 当前列表筛选条件(用于导出查询)
* @param onImported 导入成功后的回调(父组件刷新列表)
*/
export function ImportExportButtons({
filter,
onImported,
}: ImportExportButtonsProps): React.ReactElement {
const t = useTranslations("admin.questions.importExport");
const tCommon = useTranslations("common");
const importMutation = useImportQuestions();
const exportQuery = useExportQuestions(filter);
const fileInputRef = useRef<HTMLInputElement>(null);
const [exporting, setExporting] = useState(false);
const handleImportClick = (): void => {
fileInputRef.current?.click();
};
const handleFileChange = async (
e: React.ChangeEvent<HTMLInputElement>,
): Promise<void> => {
const file = e.target.files?.[0];
if (!file) return;
const format = file.name.toLowerCase().endsWith(".json") ? "json" : "csv";
try {
const payload = await file.text();
const result = await importMutation.run({ payload, format });
notify.success(
t("importSuccess", {
imported: result.imported,
skipped: result.skipped,
}),
);
onImported();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
} finally {
e.target.value = "";
}
};
const handleExportClick = async (): Promise<void> => {
setExporting(true);
try {
const data = await exportQuery.refetch();
if (!data || data.items.length === 0) {
notify.info(t("exportEmpty"));
return;
}
const csv = convertQuestionsToCsv(data.items);
const ok = downloadCsv("questions-export.csv", csv);
if (ok) {
notify.success(t("exportSuccess", { count: data.total }));
} else {
notify.error(t("exportFailed"));
}
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
} finally {
setExporting(false);
}
};
const isExporting = exporting || exportQuery.loading;
return (
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".csv,.json"
disabled={importMutation.loading}
onChange={handleFileChange}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleImportClick}
disabled={importMutation.loading}
>
{importMutation.loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="size-4" />
)}
{t("import")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleExportClick}
disabled={isExporting}
>
{isExporting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
{t("export")}
</Button>
</div>
);
}
/**
* 将题目列表转为 CSV 字符串(含 BOM 以兼容 Excel 中文)。
*
* 纯函数,便于单测。对齐 audit-logs/transformations.ts 的 downloadCsv 模式。
*/
function convertQuestionsToCsv(items: ExportQuestionItem[]): string {
const headers = [
"id",
"type",
"content",
"difficulty",
"answer",
"explanation",
"knowledgePointId",
"subjectId",
"source",
"status",
"createdAt",
];
const escapeCell = (value: string | null | undefined): string => {
if (value === null || value === undefined) return "";
const s = String(value);
if (s.includes(",") || s.includes("\n") || s.includes('"')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
};
const rows = items.map((item) =>
[
item.id,
item.type,
item.content,
item.difficulty,
item.answer,
item.explanation ?? "",
item.knowledgePointId,
item.subjectId,
item.source,
item.status,
item.createdAt,
]
.map(escapeCell)
.join(","),
);
return `\uFEFF${headers.join(",")}\n${rows.join("\n")}`;
}
/**
* 触发浏览器下载 CSV 文件。返回是否成功。
*/
function downloadCsv(filename: string, csv: string): boolean {
if (typeof window === "undefined") return false;
try {
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,307 @@
"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>
);
}

View File

@@ -5,6 +5,7 @@
*
* 数据契约:
* - 列表查询 adminQuestions(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 创建/导入/导出/批量删除:均 ❌ schema 未就绪 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
*
* URL 状态:?type=&difficulty=&subjectId=&q=
@@ -14,9 +15,8 @@
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { HelpCircle } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useAdminQuestions, type AdminQuestionListItem } from "@/lib/api";
@@ -37,6 +37,15 @@ import {
questionTypeToBadgeClass,
truncateContent,
} from "@/features/admin/questions/transformations";
import { CreateQuestionDialog } from "@/features/admin/questions/create-question-dialog";
import { ImportExportButtons } from "@/features/admin/questions/import-export-buttons";
import { BatchOperations } from "@/features/admin/questions/batch-operations";
import { QuestionDetailDialog } from "@/features/admin/questions/question-detail-dialog";
/** 每页条数 */
const PAGE_SIZE = 10;
/** 分页组件最多展示的页码按钮数(奇数,便于左右对称) */
const MAX_PAGE_BUTTONS = 7;
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -53,9 +62,16 @@ export function AdminQuestionsListClient(): React.ReactElement {
const difficultyFilter = searchParams.get("difficulty") ?? "";
const subjectId = searchParams.get("subjectId") ?? "";
const q = searchParams.get("q") ?? "";
const page = Number(searchParams.get("page") ?? "1") || 1;
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedQuestionId, setSelectedQuestionId] = useState<string | null>(
null,
);
// @contract-pendingMSW 兜底
const { data, loading, error } = useAdminQuestions({
const { data, loading, error, refetch } = useAdminQuestions({
type: typeFilter || null,
difficulty: difficultyFilter || null,
subjectId: subjectId || null,
@@ -70,6 +86,14 @@ export function AdminQuestionsListClient(): React.ReactElement {
return items.filter((item) => item.content.toLowerCase().includes(lower));
}, [data, q]);
const total = data?.total ?? filteredItems.length;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(Math.max(1, page), totalPages);
const pagedItems = useMemo<AdminQuestionListItem[]>(() => {
const start = (safePage - 1) * PAGE_SIZE;
return filteredItems.slice(start, start + PAGE_SIZE);
}, [filteredItems, safePage]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
@@ -77,6 +101,10 @@ export function AdminQuestionsListClient(): React.ReactElement {
} else {
params.delete(key);
}
// 切换筛选时重置页码
if (key !== "page") {
params.delete("page");
}
startTransition(() => {
router.push(`/shell/admin/questions?${params.toString()}`);
});
@@ -103,89 +131,194 @@ export function AdminQuestionsListClient(): React.ReactElement {
/>
);
const handleCreateClick = (): void => {
setCreateDialogOpen(true);
};
const handleCreated = (): void => {
void refetch();
};
const handleImported = (): void => {
void refetch();
};
const handleClearSelection = (): void => {
setSelectedIds([]);
};
const handleBatchDeleted = (): void => {
setSelectedIds([]);
void refetch();
};
const handleSelectItem = (id: string, checked: boolean): void => {
setSelectedIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id),
);
};
const handleSelectAll = (checked: boolean): void => {
setSelectedIds(checked ? pagedItems.map((item) => item.id) : []);
};
const exportFilter = {
type: typeFilter || undefined,
difficulty: difficultyFilter || undefined,
subjectId: subjectId || undefined,
q: q || undefined,
};
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<HelpCircle className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/admin/questions">{t("createButton")}</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
<>
<CreateQuestionDialog
open={createDialogOpen}
onClose={() => setCreateDialogOpen(false)}
onCreated={handleCreated}
/>
<QuestionDetailDialog
questionId={selectedQuestionId}
onOpenChange={(open) => {
if (!open) setSelectedQuestionId(null);
}}
/>
<ListPageShell
title={t("title")}
description={t("description")}
icon={<HelpCircle className="size-6" />}
actions={
<div className="flex items-center gap-2">
<Button onClick={handleCreateClick}>{t("createButton")}</Button>
<ImportExportButtons
filter={exportFilter}
onImported={handleImported}
/>
</div>
}
filters={
<>
<FilterSearchInput
placeholder={t("searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<select
value={typeFilter}
onChange={(e) => updateQuery("type", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterType")}
>
<option value="">{t("typeAll")}</option>
<option value="single_choice">{t("typeSingle")}</option>
<option value="multiple_choice">{t("typeMultiple")}</option>
<option value="fill_blank">{t("typeFill")}</option>
<option value="essay">{t("typeEssay")}</option>
<option value="true_false">{t("typeTrueFalse")}</option>
</select>
<select
value={difficultyFilter}
onChange={(e) => updateQuery("difficulty", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterDifficulty")}
>
<option value="">{t("difficultyAll")}</option>
<option value="easy">{t("difficultyEasy")}</option>
<option value="medium">{t("difficultyMedium")}</option>
<option value="hard">{t("difficultyHard")}</option>
</select>
<input
type="text"
value={subjectId}
onChange={(e) => updateQuery("subjectId", e.target.value)}
placeholder={t("filterSubject")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterSubject")}
/>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<Pagination
page={safePage}
pageSize={PAGE_SIZE}
total={total}
onJump={(p) => updateQuery("page", String(p))}
/>
<select
value={typeFilter}
onChange={(e) => updateQuery("type", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterType")}
>
<option value="">{t("typeAll")}</option>
<option value="single_choice">{t("typeSingle")}</option>
<option value="multiple_choice">{t("typeMultiple")}</option>
<option value="fill_blank">{t("typeFill")}</option>
<option value="essay">{t("typeEssay")}</option>
<option value="true_false">{t("typeTrueFalse")}</option>
</select>
<select
value={difficultyFilter}
onChange={(e) => updateQuery("difficulty", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterDifficulty")}
>
<option value="">{t("difficultyAll")}</option>
<option value="easy">{t("difficultyEasy")}</option>
<option value="medium">{t("difficultyMedium")}</option>
<option value="hard">{t("difficultyHard")}</option>
</select>
<input
type="text"
value={subjectId}
onChange={(e) => updateQuery("subjectId", e.target.value)}
placeholder={t("filterSubject")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterSubject")}
}
>
<div className="flex flex-col gap-3">
{selectedIds.length > 0 ? (
<BatchOperations
selectedIds={selectedIds}
onClear={handleClearSelection}
onDeleted={handleBatchDeleted}
/>
) : null}
<AdminQuestionsTable
items={pagedItems}
selectedIds={selectedIds}
onSelectItem={handleSelectItem}
onSelectAll={handleSelectAll}
onViewDetail={setSelectedQuestionId}
/>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>
{t("total", { count: data?.total ?? filteredItems.length })}
</span>
</div>
}
>
<AdminQuestionsTable items={filteredItems} />
</ListPageShell>
</ListPageShell>
</>
);
}
/**
* 题目列表表格(纯展示组件,对齐 §8.2 排版规范)。
*
* - 第一列为 checkbox支持单选 / 全选)
* - 选中态由父组件维护selectedIds本组件仅展示与回调
* - 当题目数超过 100 时启用虚拟滚动优化CSS content-visibility: auto
* 浏览器自动跳过视口外行的渲染,降低首次绘制与滚动开销,无需引入新依赖)
*/
function AdminQuestionsTable({
items,
selectedIds,
onSelectItem,
onSelectAll,
onViewDetail,
}: {
items: AdminQuestionListItem[];
selectedIds: string[];
onSelectItem: (id: string, checked: boolean) => void;
onSelectAll: (checked: boolean) => void;
onViewDetail: (id: string) => void;
}): React.ReactElement {
const t = useTranslations("admin.questions.list");
const allChecked =
items.length > 0 && items.every((item) => selectedIds.includes(item.id));
const someChecked = items.some((item) => selectedIds.includes(item.id));
// 当题目数超过 100 时启用虚拟滚动优化CSS content-visibility
const enableVirtualScroll = items.length > 100;
const rowStyle = enableVirtualScroll
? { contentVisibility: "auto" as const, containIntrinsicSize: "0 80px" }
: undefined;
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="w-10 p-3 text-left">
<input
type="checkbox"
checked={allChecked}
ref={(el) => {
if (el) el.indeterminate = !allChecked && someChecked;
}}
onChange={(e) => onSelectAll(e.target.checked)}
aria-label={t("selectAll")}
className="size-4 cursor-pointer rounded border-input"
/>
</th>
<th className="p-3 text-left font-medium">{t("colContent")}</th>
<th className="p-3 text-left font-medium">{t("colType")}</th>
<th className="p-3 text-left font-medium">{t("colDifficulty")}</th>
@@ -197,45 +330,59 @@ function AdminQuestionsTable({
</tr>
</thead>
<tbody className="divide-y">
{items.map((q) => (
<tr key={q.id} className="hover:bg-muted/30">
<td className="max-w-xs p-3">
<span className="font-medium">
{truncateContent(q.content)}
</span>
</td>
<td className="p-3">
<QuestionTypeBadge type={q.type} />
</td>
<td className="p-3">
<span
className={`text-xs font-medium ${difficultyToColorClass(q.difficulty)}`}
>
{formatDifficulty(q.difficulty)}
</span>
</td>
<td className="p-3 text-xs text-muted-foreground">
{q.subjectName || q.subjectId || "-"}
</td>
<td className="p-3">
<QuestionStatusBadge status={q.status} />
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatQuestionDate(q.createdAt)}
</td>
<td className="p-3 text-xs text-muted-foreground">
{q.createdBy || "-"}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/admin/questions?id=${q.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("viewDetail")}
</Link>
</td>
</tr>
))}
{items.map((q) => {
const checked = selectedIds.includes(q.id);
return (
<tr key={q.id} className="hover:bg-muted/30" style={rowStyle}>
<td className="p-3">
<input
type="checkbox"
checked={checked}
onChange={(e) => onSelectItem(q.id, e.target.checked)}
aria-label={t("selectRow")}
className="size-4 cursor-pointer rounded border-input"
/>
</td>
<td className="max-w-xs p-3">
<span className="font-medium">
{truncateContent(q.content)}
</span>
</td>
<td className="p-3">
<QuestionTypeBadge type={q.type} />
</td>
<td className="p-3">
<span
className={`text-xs font-medium ${difficultyToColorClass(q.difficulty)}`}
>
{formatDifficulty(q.difficulty)}
</span>
</td>
<td className="p-3 text-xs text-muted-foreground">
{q.subjectName || q.subjectId || "-"}
</td>
<td className="p-3">
<QuestionStatusBadge status={q.status} />
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatQuestionDate(q.createdAt)}
</td>
<td className="p-3 text-xs text-muted-foreground">
{q.createdBy || "-"}
</td>
<td className="p-3 text-right">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => onViewDetail(q.id)}
>
{t("viewDetail")}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -275,3 +422,111 @@ function QuestionStatusBadge({
</span>
);
}
/**
* 分页组件(页码列表 + 跳转按钮 + total/totalPages 显示)。
* 依赖 URL ?page=N 状态,由父组件控制路由跳转。
* 页码按钮策略:当 totalPages ≤ MAX_PAGE_BUTTONS 时全量展示;
* 超过时展示首尾页 + 当前页附近的页码(含省略号占位)。
*/
function Pagination({
page,
pageSize,
total,
onJump,
}: {
page: number;
pageSize: number;
total: number;
onJump: (page: number) => void;
}): React.ReactElement {
const t = useTranslations("admin.questions.list");
const tCommon = useTranslations("common");
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const canPrev = page > 1;
const canNext = page < totalPages;
const pages = buildPageList(page, totalPages, MAX_PAGE_BUTTONS);
return (
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span>{t("total", { count: total })}</span>
<span className="text-xs">
{page} / {totalPages}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onJump(Math.max(1, page - 1))}
disabled={!canPrev}
aria-label={tCommon("button.prev")}
>
{tCommon("button.prev")}
</Button>
{pages.map((p, idx) =>
p === "..." ? (
<span
key={`gap-${idx}`}
className="px-2 text-xs text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="sm"
onClick={() => onJump(p)}
aria-current={p === page ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="sm"
onClick={() => onJump(Math.min(totalPages, page + 1))}
disabled={!canNext}
aria-label={tCommon("button.next")}
>
{tCommon("button.next")}
</Button>
</div>
</div>
);
}
/**
* 构造页码列表:当总页数不超过 maxButtons 时全部展示;
* 否则展示首尾页与当前页附近页码,省略位置用 "..." 占位。
*/
function buildPageList(
current: number,
total: number,
maxButtons: number,
): Array<number | "..."> {
if (total <= maxButtons) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const half = Math.floor(maxButtons / 2);
const start = Math.max(2, current - half + 1);
const end = Math.min(total - 1, start + maxButtons - 4);
const adjustedStart =
end - start < maxButtons - 4 ? Math.max(2, end - (maxButtons - 5)) : start;
const result: Array<number | "..."> = [1];
if (adjustedStart > 2) {
result.push("...");
}
for (let p = adjustedStart; p <= end; p += 1) {
result.push(p);
}
if (end < total - 1) {
result.push("...");
}
result.push(total);
return result;
}

View File

@@ -19,6 +19,7 @@ import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useRole, type RoleDetail } from "@/lib/api";
import { RolePermissionMatrix } from "./role-permission-matrix";
import {
DetailPageShell,
DetailPageSkeleton,
@@ -103,6 +104,15 @@ function RoleDetailBody({ role }: { role: RoleDetail }): React.ReactElement {
<PermissionsMatrix permissions={role.permissions} />
)}
</DetailSection>
<DetailSection title={t("sectionPermissionMatrix")}>
<RolePermissionMatrix
roleId={role.id}
roleName={role.name}
isLocked={locked}
userCount={role.userCount}
/>
</DetailSection>
</>
);
}

View File

@@ -0,0 +1,213 @@
"use client";
/**
* Role create/edit dialog - lightweight modal (ARCHITECTURE.md 9.4 / 10 P5)
*
* Features:
* - Create mode: new role, refresh list on submit
* - Edit mode: edit existing role name and description
* - Zod pattern validation for role name (^[a-z0-9_]+$)
* - Value field (optional human-readable label)
*
* Data contract:
* - mutation createRole / updateRole: schema pending -> MSW fallback (@contract-pending)
*
* Related: ARCHITECTURE.md 5.4 / 9.4 / 10 P5 / 11.3
*/
import { Shield } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, type FormEvent } from "react";
import { useTranslations } from "next-intl";
import { z } from "zod";
import { useCreateRole, useUpdateRole, type Role } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { notify } from "@/shared/lib/notify";
/** 角色名称 zod 校验小写字母、数字、下划线2-50 字符。 */
const roleNameSchema = z
.string()
.min(2)
.max(50)
.regex(/^[a-z0-9_]+$/);
export interface RoleFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editRole?: Role | null;
}
export function RoleFormDialog({
open,
onOpenChange,
editRole,
}: RoleFormDialogProps): React.ReactElement | null {
const t = useTranslations("admin.roles.createDialog");
const tCommon = useTranslations("common");
const router = useRouter();
const isEdit = Boolean(editRole);
const isLocked = editRole?.isLocked ?? false;
const { run: createRole, loading: creating } = useCreateRole();
const { run: updateRole, loading: updating } = useUpdateRole();
const [name, setName] = useState("");
const [value, setValue] = useState("");
const [description, setDescription] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setName(editRole?.name ?? "");
setValue(editRole?.value ?? "");
setDescription(editRole?.description ?? "");
setNameError(null);
}
}, [open, editRole]);
const isWorking = creating || updating;
const handleClose = (): void => {
onOpenChange(false);
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
setNameError(t("errorNameRequired"));
return;
}
const nameValidation = roleNameSchema.safeParse(trimmedName);
if (!nameValidation.success) {
setNameError(t("errorNamePattern"));
return;
}
setNameError(null);
const trimmedValue = value.trim();
const input = {
name: trimmedName,
value: trimmedValue || undefined,
description: description.trim() || undefined,
permissionIds: editRole?.permissions?.map((p) => p.id) ?? [],
};
try {
if (isEdit && editRole) {
await updateRole(editRole.id, input);
notify.success(t("successUpdated"));
} else {
await createRole(input);
notify.success(t("successCreated"));
}
onOpenChange(false);
router.refresh();
} catch (err) {
notify.error(tCommon("error.operationFailed", { message: String(err) }));
}
};
if (!open) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="size-5 text-primary" />
{isEdit ? t("titleEdit") : t("titleCreate")}
</DialogTitle>
<DialogDescription>
{isEdit ? t("descriptionEdit") : t("descriptionCreate")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="role-name">{t("fieldName")}</Label>
<Input
id="role-name"
value={name}
onChange={(e) => {
setName(e.target.value);
if (nameError) setNameError(null);
}}
placeholder={t("fieldNamePlaceholder")}
disabled={isLocked || isWorking}
required
minLength={2}
maxLength={50}
pattern="^[a-z0-9_]+$"
title={t("namePatternTitle")}
aria-invalid={nameError ? true : undefined}
/>
{nameError ? (
<p className="text-xs text-destructive">{nameError}</p>
) : null}
{isLocked ? (
<p className="text-xs text-muted-foreground">
{t("lockedNameNotice")}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="role-value">{t("fieldValue")}</Label>
<Input
id="role-value"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t("fieldValuePlaceholder")}
disabled={isLocked || isWorking}
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="role-description">{t("fieldDescription")}</Label>
<Textarea
id="role-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("fieldDescriptionPlaceholder")}
disabled={isWorking}
maxLength={255}
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isWorking}
>
{t("cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{isWorking
? t("submitting")
: isEdit
? t("submitEdit")
: t("submitCreate")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,348 @@
"use client";
/**
* Role permission matrix - CRUD actions per permission point (ARCHITECTURE.md 9.4 / 10 P5)
*
* Features:
* - Table with rows = permission points (grouped by module)
* - Columns = CRUD actions (read, create, update, delete)
* - Cells = checkboxes to toggle actions
* - Save button to persist changes
* - Locked roles are read-only
* - Search filter (by permission label / module)
* - Collapse/expand per module group
* - User-impact Alert when role has associated users
*
* Data contract:
* - query rolePermissions / mutation updateRolePermissionActions: schema pending -> MSW fallback (@contract-pending)
*
* Related: ARCHITECTURE.md 5.4 / 9.4 / 10 P5 / 11.3
*/
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Lock,
Save,
Search,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import {
useRolePermissions,
useUpdateRolePermissionActions,
type PermissionActions,
type RolePermissionMatrixItem,
} from "@/lib/api";
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { notify } from "@/shared/lib/notify";
export interface RolePermissionMatrixProps {
roleId: string;
roleName: string;
isLocked: boolean;
/** 关联用户数(用于展示用户影响 Alert@contract-pending MSW 兜底) */
userCount?: number;
}
const ACTION_KEYS: Array<{ key: keyof PermissionActions; labelKey: string }> = [
{ key: "read", labelKey: "actionRead" },
{ key: "create", labelKey: "actionCreate" },
{ key: "update", labelKey: "actionUpdate" },
{ key: "delete", labelKey: "actionDelete" },
];
export function RolePermissionMatrix({
roleId,
roleName,
isLocked,
userCount,
}: RolePermissionMatrixProps): React.ReactElement {
const t = useTranslations("admin.roles.matrix");
const tCommon = useTranslations("common");
const { data, loading, error } = useRolePermissions(roleId);
const { run: updateActions, loading: saving } =
useUpdateRolePermissionActions();
const [permissions, setPermissions] = useState<RolePermissionMatrixItem[]>(
[],
);
const [search, setSearch] = useState("");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
useEffect(() => {
setPermissions(data ?? []);
}, [data]);
// 搜索过滤:按权限点 label 或 module 命中(大小写不敏感)
const filteredPermissions = useMemo<RolePermissionMatrixItem[]>(() => {
const q = search.trim().toLowerCase();
if (!q) return permissions;
return permissions.filter((p) => {
if (p.label.toLowerCase().includes(q)) return true;
if (p.module.toLowerCase().includes(q)) return true;
return false;
});
}, [permissions, search]);
const grouped = useMemo(
() => groupByModule(filteredPermissions),
[filteredPermissions],
);
const hasChanges = useMemo(
() => JSON.stringify(permissions) !== JSON.stringify(data ?? []),
[permissions, data],
);
const handleToggle = (
permissionId: string,
action: keyof PermissionActions,
): void => {
setPermissions((prev) =>
prev.map((p) => {
if (p.permissionId !== permissionId) return p;
return {
...p,
actions: {
...p.actions,
[action]: !p.actions[action],
},
};
}),
);
};
const handleToggleCollapse = (module: string): void => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(module)) {
next.delete(module);
} else {
next.add(module);
}
return next;
});
};
const handleSave = async (): Promise<void> => {
try {
await updateActions(
roleId,
permissions.map((p) => ({
permissionId: p.permissionId,
actions: p.actions,
})),
);
notify.success(t("successSaved"));
} catch (err) {
notify.error(tCommon("error.operationFailed", { message: String(err) }));
}
};
if (loading) {
return (
<div className="rounded-xl border p-6">
<p className="text-sm text-muted-foreground">{t("loading")}</p>
</div>
);
}
if (error) {
return (
<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>
);
}
if (permissions.length === 0) {
return (
<div className="rounded-xl border p-6 text-center">
<p className="text-sm text-muted-foreground">{t("empty")}</p>
</div>
);
}
const showUserImpactAlert = !isLocked && (userCount ?? 0) > 0;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold">
{t("title", { roleName })}
</h3>
{isLocked ? (
<Badge variant="secondary">
<Lock className="mr-1 size-3" />
{t("locked")}
</Badge>
) : null}
<Badge variant="outline">
{t("permissionCount", { count: permissions.length })}
</Badge>
</div>
{!isLocked ? (
<Button
onClick={handleSave}
disabled={!hasChanges || saving}
size="sm"
>
<Save className="size-4" />
{saving ? t("saving") : t("save")}
</Button>
) : null}
</div>
{showUserImpactAlert ? (
<Alert className="border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-400">
<AlertTriangle className="size-4" />
<AlertDescription>
{t("userImpactNotice", { count: userCount ?? 0 })}
</AlertDescription>
</Alert>
) : null}
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
className="pl-9"
aria-label={t("searchPlaceholder")}
/>
</div>
<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("colPermission")}
</th>
{ACTION_KEYS.map((action) => (
<th key={action.key} className="p-3 text-center font-medium">
{t(action.labelKey)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{grouped.size === 0 ? (
<tr>
<td
colSpan={ACTION_KEYS.length + 1}
className="p-6 text-center text-sm text-muted-foreground"
>
{tCommon("empty.searchResult")}
</td>
</tr>
) : (
Array.from(grouped.entries()).map(([module, perms]) => (
<MatrixGroup
key={module}
module={module}
permissions={perms}
isLocked={isLocked}
isCollapsed={collapsed.has(module)}
onToggleCollapse={handleToggleCollapse}
onToggle={handleToggle}
/>
))
)}
</tbody>
</table>
</div>
</div>
);
}
/**
* Matrix group - renders a collapsible module header row and permission rows.
*/
function MatrixGroup({
module,
permissions,
isLocked,
isCollapsed,
onToggleCollapse,
onToggle,
}: {
module: string;
permissions: RolePermissionMatrixItem[];
isLocked: boolean;
isCollapsed: boolean;
onToggleCollapse: (module: string) => void;
onToggle: (permissionId: string, action: keyof PermissionActions) => void;
}): React.ReactElement {
const t = useTranslations("admin.roles.matrix");
return (
<>
<tr className="bg-muted/20">
<td colSpan={5} className="p-0">
<button
type="button"
onClick={() => onToggleCollapse(module)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-semibold uppercase text-muted-foreground hover:bg-muted/40"
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? t("expand") : t("collapse")}
>
{isCollapsed ? (
<ChevronRight className="size-3.5" />
) : (
<ChevronDown className="size-3.5" />
)}
{t("moduleLabel", { module })}
<Badge variant="outline" className="ml-1">
{permissions.length}
</Badge>
</button>
</td>
</tr>
{!isCollapsed
? permissions.map((perm) => (
<tr key={perm.permissionId} className="hover:bg-muted/30">
<td className="p-3">{perm.label}</td>
{ACTION_KEYS.map((action) => (
<td key={action.key} className="p-3 text-center">
<input
type="checkbox"
checked={perm.actions[action.key]}
onChange={() => onToggle(perm.permissionId, action.key)}
disabled={isLocked}
className="size-4 rounded border-input accent-primary"
aria-label={`${perm.label} ${t(action.labelKey)}`}
/>
</td>
))}
</tr>
))
: null}
</>
);
}
/**
* Group permissions by module.
*/
function groupByModule(
permissions: RolePermissionMatrixItem[],
): Map<string, RolePermissionMatrixItem[]> {
const groups = new Map<string, RolePermissionMatrixItem[]>();
for (const perm of permissions) {
const group = groups.get(perm.module) ?? [];
group.push(perm);
groups.set(perm.module, group);
}
return groups;
}

View File

@@ -13,13 +13,23 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { ShieldCheck } from "lucide-react";
import { Plus, Power, Shield, ShieldCheck, Trash2 } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useRoles, type Role } from "@/lib/api";
import {
useDeleteRole,
useRoles,
useToggleRoleEnabled,
type Role,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { RoleFormDialog } from "./role-form-dialog";
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
@@ -29,12 +39,15 @@ import {
import {
countRolePermissions,
formatPermissionCount,
formatRoleDate,
formatRoleDescription,
isRoleLocked,
lockedToBadgeClass,
matchRoleSearch,
} from "@/features/admin/roles/transformations";
/** 新建角色 URL 参数标记 */
const NEW_ROLE_PARAM = "new";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
@@ -45,6 +58,83 @@ export function RolesListClient(): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const [dialogOpen, setDialogOpen] = useState(false);
const [editRole, setEditRole] = useState<Role | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
const deleteRole = useDeleteRole();
const toggleRoleEnabled = useToggleRoleEnabled();
const handleEdit = (role: Role): void => {
setEditRole(role);
setDialogOpen(true);
};
const handleDelete = async (): Promise<void> => {
if (!deleteTarget) return;
setPendingId(deleteTarget.id);
try {
await deleteRole.run(deleteTarget.id);
notify.success(t("list.deleted"));
setDeleteTarget(null);
router.refresh();
} catch (e) {
notify.error(String(e));
} finally {
setPendingId(null);
}
};
const handleToggleEnabled = async (role: Role): Promise<void> => {
if (isRoleLocked(role)) {
notify.warning(t("list.lockedRoleToggleWarn"));
return;
}
const nextEnabled = !(role.isEnabled ?? true);
setPendingId(role.id);
try {
await toggleRoleEnabled.run(role.id, nextEnabled);
notify.success(
nextEnabled ? t("list.enabledSuccess") : t("list.disabledSuccess"),
);
router.refresh();
} catch (e) {
notify.error(String(e));
} finally {
setPendingId(null);
}
};
// ?new=1 时自动打开创建对话框
useEffect(() => {
if (searchParams.get(NEW_ROLE_PARAM) === "1" && !dialogOpen) {
setEditRole(null);
setDialogOpen(true);
}
// 仅在 new 参数首次进入时触发
}, [searchParams]);
const handleDialogOpenChange = (open: boolean): void => {
setDialogOpen(open);
if (!open) {
// 关闭对话框时清理 URL 上的 new 参数
const params = new URLSearchParams(searchParams.toString());
if (params.has(NEW_ROLE_PARAM)) {
params.delete(NEW_ROLE_PARAM);
startTransition(() => {
const qs = params.toString();
router.push(qs ? `/shell/admin/roles?${qs}` : "/shell/admin/roles");
});
}
}
};
const triggerCreateViaUrl = (): void => {
startTransition(() => {
router.push("/shell/admin/roles?new=1");
});
};
const search = searchParams.get("search") ?? "";
@@ -86,55 +176,111 @@ export function RolesListClient(): React.ReactElement {
description={t("list.emptyDescription")}
action={{
label: t("list.emptyAction"),
// 新建功能未开放,指向当前页占位(避免死链)
href: "/shell/admin/roles",
// 通过 URL 参数触发对话框打开,保证状态可被分享/刷新
onClick: triggerCreateViaUrl,
}}
/>
);
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<ShieldCheck className="size-6" />}
filters={
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={search}
onChange={(v) => updateQuery("search", v)}
<>
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<ShieldCheck className="size-6" />}
actions={
<>
<Badge variant="secondary" className="tabular-nums">
{t("list.totalBadge", { count: data?.length ?? 0 })}
</Badge>
<Button onClick={triggerCreateViaUrl} size="sm">
<Plus className="size-4" />
{t("list.createButton")}
</Button>
</>
}
filters={
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={search}
onChange={(v) => updateQuery("search", v)}
/>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
>
<RolesTable
items={filteredItems}
onEdit={handleEdit}
onDelete={(r) => setDeleteTarget(r)}
onToggleEnabled={handleToggleEnabled}
pendingId={pendingId}
/>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
>
<RolesTable items={filteredItems} />
</ListPageShell>
</ListPageShell>
<RoleFormDialog
open={dialogOpen}
onOpenChange={handleDialogOpenChange}
editRole={editRole}
/>
<ConfirmDeleteDialog
open={!!deleteTarget}
onOpenChange={(v) => !v && setDeleteTarget(null)}
title={t("list.deleteConfirmTitle")}
description={t("list.deleteConfirmDescription", {
name: deleteTarget?.name ?? "",
count: deleteTarget?.userCount ?? 0,
})}
confirmText={t("list.confirmDelete")}
cancelText={tCommon("button.cancel")}
onConfirm={handleDelete}
isWorking={pendingId === deleteTarget?.id}
/>
</>
);
}
/**
* 角色列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function RolesTable({ items }: { items: Role[] }): React.ReactElement {
function RolesTable({
items,
onEdit,
onDelete,
onToggleEnabled,
pendingId,
}: {
items: Role[];
onEdit: (role: Role) => void;
onDelete: (role: Role) => void;
onToggleEnabled: (role: Role) => void;
pendingId: string | null;
}): React.ReactElement {
const t = useTranslations("admin.roles");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<caption className="sr-only">{t("list.tableCaption")}</caption>
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
<th className="p-3 text-left font-medium">{t("list.colValue")}</th>
<th className="p-3 text-left font-medium">
{t("list.colDescription")}
</th>
<th className="p-3 text-left font-medium">{t("list.colType")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colIsLocked")}
{t("list.colUserCount")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colPermissions")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colUpdatedAt")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colActions")}
</th>
@@ -144,18 +290,32 @@ function RolesTable({ items }: { items: Role[] }): React.ReactElement {
{items.map((r) => {
const locked = isRoleLocked(r);
const permCount = countRolePermissions(r);
const enabled = r.isEnabled ?? true;
const userCount = r.userCount ?? 0;
return (
<tr key={r.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{r.name}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{r.value ?? "--"}
</td>
<td className="p-3 text-muted-foreground">
{formatRoleDescription(r.description)}
</td>
<td className="p-3">
<LockedBadge locked={locked} />
<TypeBadge locked={locked} />
</td>
<td className="p-3">
<EnabledBadge enabled={enabled} />
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{t("list.userCountValue", { count: userCount })}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatPermissionCount(permCount)}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatRoleDate(r.updatedAt)}
</td>
<td className="p-3">
<div className="flex items-center gap-2">
<Link
@@ -164,12 +324,43 @@ function RolesTable({ items }: { items: Role[] }): React.ReactElement {
>
{t("list.viewDetail")}
</Link>
<Link
href={`/shell/admin/roles/${r.id}`}
<button
type="button"
onClick={() => onEdit(r)}
className="inline-flex h-8 items-center rounded-md border border-input bg-background px-2 text-xs transition-colors hover:bg-accent"
>
{t("list.editPermissions")}
</Link>
{t("list.editRole")}
</button>
<button
type="button"
onClick={() => onToggleEnabled(r)}
disabled={locked || pendingId === r.id}
aria-label={
enabled ? t("list.disableRole") : t("list.enableRole")
}
title={
locked
? t("list.lockedRole")
: enabled
? t("list.disableRole")
: t("list.enableRole")
}
className="inline-flex h-8 items-center rounded-md border border-input bg-background px-2 text-xs transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
>
<Power className="size-3.5" />
</button>
<button
type="button"
onClick={() => onDelete(r)}
disabled={locked || pendingId === r.id}
aria-label={t("list.deleteRole")}
title={
locked ? t("list.lockedRole") : t("list.deleteRole")
}
className="inline-flex h-8 items-center rounded-md border border-input bg-background px-2 text-xs text-destructive transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="size-3.5" />
</button>
</div>
</td>
</tr>
@@ -182,16 +373,39 @@ function RolesTable({ items }: { items: Role[] }): React.ReactElement {
}
/**
* 系统锁定徽章。
* 类型徽章:系统角色显示 ShieldCheck 实心徽章,自定义角色显示 Shield 轮廓徽章。
*/
function LockedBadge({ locked }: { locked: boolean }): React.ReactElement {
function TypeBadge({ locked }: { locked: boolean }): React.ReactElement {
const t = useTranslations("admin.roles");
const cls = lockedToBadgeClass(locked);
if (locked) {
return (
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-amber-500/10 px-2 text-xs font-medium text-amber-600 dark:text-amber-400">
<ShieldCheck className="size-3" />
{t("list.typeSystem")}
</span>
);
}
return (
<span className="inline-flex h-6 items-center gap-1 rounded-full border border-input px-2 text-xs font-medium text-muted-foreground">
<Shield className="size-3" />
{t("list.typeCustom")}
</span>
);
}
/**
* 启用/停用状态徽章。
*/
function EnabledBadge({ enabled }: { enabled: boolean }): React.ReactElement {
const t = useTranslations("admin.roles");
const cls = enabled
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground";
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{locked ? t("list.lockedRole") : "--"}
{enabled ? t("list.enabled") : t("list.disabled")}
</span>
);
}

View File

@@ -95,6 +95,23 @@ export function formatRoleDescription(
return description;
}
/**
* 格式化角色更新时间 ISO 字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatRoleDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 模糊匹配角色搜索关键字(按 name / description 命中,大小写不敏感)。
* 关键字为空时返回 true。

View File

@@ -13,7 +13,7 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { Calendar, Pencil, Plus, Trash2 } from "lucide-react";
import { Calendar, CheckCircle2, Pencil, Plus, Trash2 } from "lucide-react";
import { useSearchParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
@@ -29,7 +29,23 @@ import {
type SchoolListItem,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Input } from "@/shared/components/ui/input";
@@ -37,6 +53,14 @@ import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
import {
activeToBadgeClass,
formatSchoolDay,
@@ -207,15 +231,21 @@ export function AcademicYearClient(): React.ReactElement {
</div>
}
>
<AcademicYearTable
items={filteredItems}
schoolNameMap={schoolNameMap}
onEdit={(y) => {
setEditTarget(y);
setFormOpen(true);
}}
onDelete={(y) => setDeleteTarget(y)}
/>
<div className="grid gap-6 lg:grid-cols-3">
<ActiveYearSidebarCard
items={filteredItems}
schoolNameMap={schoolNameMap}
/>
<AcademicYearTableCard
items={filteredItems}
schoolNameMap={schoolNameMap}
onEdit={(y) => {
setEditTarget(y);
setFormOpen(true);
}}
onDelete={(y) => setDeleteTarget(y)}
/>
</div>
{formOpen ? (
<AcademicYearFormDialog
editTarget={editTarget}
@@ -244,9 +274,114 @@ export function AcademicYearClient(): React.ReactElement {
}
/**
* 学年列表表格
* 当前激活学年的侧栏卡片lg:col-span-1
* 若列表中存在 isActive=true 的项则展示该卡片,
* 否则展示"暂无激活学年"的空态提示。
*/
function AcademicYearTable({
function ActiveYearSidebarCard({
items,
schoolNameMap,
}: {
items: AcademicYear[];
schoolNameMap: Map<string, string>;
}): React.ReactElement {
const t = useTranslations("admin.school.academicYear");
const activeYear = items.find((y) => y.isActive) ?? null;
if (!activeYear) {
return (
<Card className="lg:col-span-1 shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">
{t("activeYearCardTitle")}
</CardTitle>
<CardDescription>{t("activeYearCardDescription")}</CardDescription>
</div>
<CheckCircle2
className="size-5 text-muted-foreground"
aria-hidden="true"
/>
</CardHeader>
<CardContent>
<p className="py-2 text-sm text-muted-foreground">
{t("activeYearCardEmpty")}
</p>
</CardContent>
</Card>
);
}
return (
<Card className="lg:col-span-1 shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">
{t("activeYearCardTitle")}
</CardTitle>
<CardDescription>{t("activeYearCardDescription")}</CardDescription>
</div>
<CheckCircle2
className="size-5 text-emerald-600 dark:text-emerald-400"
aria-hidden="true"
/>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{t("colName")}</div>
<div className="text-lg font-semibold">
{truncateText(activeYear.name)}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
{t("colSchool")}
</div>
<div className="text-sm text-muted-foreground">
{schoolNameMap.get(activeYear.schoolId) ?? activeYear.schoolId}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
{t("colStartDate")}
</div>
<div className="font-mono text-xs text-muted-foreground">
{formatSchoolDay(activeYear.startDate)}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
{t("colEndDate")}
</div>
<div className="font-mono text-xs text-muted-foreground">
{formatSchoolDay(activeYear.endDate)}
</div>
</div>
</div>
<div>
<Badge
variant={activeYear.isActive ? "secondary" : "outline"}
className={
activeYear.isActive
? activeToBadgeClass(activeYear.isActive)
: ""
}
>
{activeYear.isActive ? t("active") : t("inactive")}
</Badge>
</div>
</div>
</CardContent>
</Card>
);
}
/**
* 学年列表表格卡片lg:col-span-2与侧栏激活学年卡片并排展示。
*/
function AcademicYearTableCard({
items,
schoolNameMap,
onEdit,
@@ -259,65 +394,85 @@ function AcademicYearTable({
}): React.ReactElement {
const t = useTranslations("admin.school.academicYear");
return (
<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("colName")}</th>
<th className="p-3 text-left font-medium">{t("colSchool")}</th>
<th className="p-3 text-left font-medium">{t("colStartDate")}</th>
<th className="p-3 text-left font-medium">{t("colEndDate")}</th>
<th className="p-3 text-left font-medium">{t("colIsActive")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((y) => (
<tr key={y.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{truncateText(y.name)}</td>
<td className="p-3 text-muted-foreground">
{schoolNameMap.get(y.schoolId) ?? y.schoolId}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatSchoolDay(y.startDate)}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatSchoolDay(y.endDate)}
</td>
<td className="p-3">
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${activeToBadgeClass(
y.isActive,
)}`}
>
{y.isActive ? t("active") : t("inactive")}
</span>
</td>
<td className="p-3 text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(y)}
aria-label={t("edit")}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(y)}
aria-label={t("delete")}
>
<Trash2 className="size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Card className="lg:col-span-2 shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("title")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{items.length}
</Badge>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyTitle")}
</p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t("colName")}</TableHead>
<TableHead>{t("colSchool")}</TableHead>
<TableHead>{t("colStartDate")}</TableHead>
<TableHead>{t("colEndDate")}</TableHead>
<TableHead>{t("colIsActive")}</TableHead>
<TableHead className="text-right">
{t("colActions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((y) => (
<TableRow key={y.id}>
<TableCell className="font-medium">
{truncateText(y.name)}
</TableCell>
<TableCell className="text-muted-foreground">
{schoolNameMap.get(y.schoolId) ?? y.schoolId}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatSchoolDay(y.startDate)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatSchoolDay(y.endDate)}
</TableCell>
<TableCell>
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${activeToBadgeClass(
y.isActive,
)}`}
>
{y.isActive ? t("active") : t("inactive")}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(y)}
aria-label={t("edit")}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(y)}
aria-label={t("delete")}
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
}
@@ -373,17 +528,13 @@ function AcademicYearFormDialog({
};
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-4 text-lg font-semibold">
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
</h2>
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<FormField label={t("form.fieldName")} required>
<Input
@@ -425,23 +576,27 @@ function AcademicYearFormDialog({
/>
</FormField>
<FormField label={t("form.fieldIsActive")}>
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="size-4 cursor-pointer"
/>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={isActive}
onCheckedChange={(checked) => setIsActive(checked)}
aria-label={t("form.fieldIsActive")}
/>
<span className="text-muted-foreground">
{t("form.fieldIsActiveHint")}
</span>
</label>
</FormField>
<div className="flex justify-end gap-2">
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("form.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{t("form.submit")}
</Button>
</div>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -6,6 +6,7 @@
* 数据契约:
* - 列表查询 adminClasses():❌ schema 无 → MSW 兜底(@contract-pending
* - 班级 CRUD 契约未就绪,本页为只读列表(@contract-pending
* - 课表 CRUD / 邀请码管理:❌ schema 无 → MSW 兜底(@contract-pending
*
* URL 状态:?schoolId=&gradeId=&q=
*
@@ -13,24 +14,49 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { School } from "lucide-react";
import { Calendar, Pencil, Plus, School, Ticket, Trash2 } from "lucide-react";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import {
useAdminClasses,
useCreateAdminClass,
useDeleteAdminClass,
useUpdateAdminClass,
useGrades,
useSchools,
useTeacherOptions,
type AdminClassInput,
type AdminClassListItem,
type SchoolListItem,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Input } from "@/shared/components/ui/input";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { truncateText } from "@/features/admin/school/transformations";
import {
formatSchoolDate,
truncateText,
} from "@/features/admin/school/transformations";
import {
DeleteConfirmDialog,
FormField,
} from "@/features/admin/school/schools-client";
import { ScheduleManagerDialog } from "@/features/admin/school/class-schedule-dialog";
import { ClassInvitationManagerDialog } from "@/features/admin/school/class-invitation-manager";
/**
* 班级列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -48,9 +74,32 @@ export function AdminClassesClient(): React.ReactElement {
const gradeId = searchParams.get("gradeId") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useAdminClasses();
const { data, loading, error, refetch } = useAdminClasses();
const { data: schools } = useSchools();
const { data: grades } = useGrades();
const { data: teacherOptions } = useTeacherOptions();
const createMutation = useCreateAdminClass();
const updateMutation = useUpdateAdminClass();
const deleteMutation = useDeleteAdminClass();
// 课表管理 / 邀请码管理 / 新建编辑 / 删除对话框状态
const [scheduleTarget, setScheduleTarget] =
useState<AdminClassListItem | null>(null);
const [invitationTarget, setInvitationTarget] =
useState<AdminClassListItem | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [editTarget, setEditTarget] = useState<AdminClassListItem | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminClassListItem | null>(
null,
);
const teacherNameMap = useMemo<Map<string, string>>(() => {
const map = new Map<string, string>();
for (const t of teacherOptions ?? []) {
map.set(t.id, t.name);
}
return map;
}, [teacherOptions]);
const filteredItems = useMemo<AdminClassListItem[]>(() => {
const items = data ?? [];
@@ -59,16 +108,18 @@ export function AdminClassesClient(): React.ReactElement {
if (gradeId && c.gradeId !== gradeId) return false;
if (q) {
const lower = q.toLowerCase();
const headTeacherName =
teacherNameMap.get(c.headTeacherId) ?? c.headTeacherName;
if (
!c.name.toLowerCase().includes(lower) &&
!c.headTeacherName.toLowerCase().includes(lower)
!headTeacherName.toLowerCase().includes(lower)
) {
return false;
}
}
return true;
});
}, [data, q, schoolId, gradeId]);
}, [data, q, schoolId, gradeId, teacherNameMap]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
@@ -82,6 +133,37 @@ export function AdminClassesClient(): React.ReactElement {
});
};
const handleSubmit = async (
id: string | null,
input: AdminClassInput,
): Promise<void> => {
try {
if (id) {
await updateMutation.run(id, input);
} else {
await createMutation.run(input);
}
await refetch();
notify.success(id ? t("form.titleEdit") : t("form.titleCreate"));
setFormOpen(false);
setEditTarget(null);
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const handleDelete = async (): Promise<void> => {
if (!deleteTarget) return;
try {
await deleteMutation.run(deleteTarget.id);
await refetch();
notify.success(t("deleteConfirm.title"));
setDeleteTarget(null);
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
@@ -98,7 +180,10 @@ export function AdminClassesClient(): React.ReactElement {
description={t("emptyDescription")}
action={{
label: t("emptyAction"),
href: "/shell/admin/school/classes",
onClick: () => {
setEditTarget(null);
setFormOpen(true);
},
}}
/>
);
@@ -108,6 +193,17 @@ export function AdminClassesClient(): React.ReactElement {
title={t("title")}
description={t("description")}
icon={<School className="size-6" />}
actions={
<Button
onClick={() => {
setEditTarget(null);
setFormOpen(true);
}}
>
<Plus className="mr-1 size-4" />
{t("createButton")}
</Button>
}
filters={
<>
<FilterSearchInput
@@ -154,7 +250,58 @@ export function AdminClassesClient(): React.ReactElement {
</div>
}
>
<ClassesTable items={filteredItems} />
<ClassesTable
items={filteredItems}
teacherNameMap={teacherNameMap}
onEdit={(c) => {
setEditTarget(c);
setFormOpen(true);
}}
onDelete={(c) => setDeleteTarget(c)}
onManageSchedule={(c) => setScheduleTarget(c)}
onManageInvitation={(c) => setInvitationTarget(c)}
/>
{formOpen ? (
<ClassFormDialog
editTarget={editTarget}
schools={schools ?? []}
grades={grades ?? []}
teacherOptions={teacherOptions ?? []}
loading={createMutation.loading || updateMutation.loading}
onClose={() => {
setFormOpen(false);
setEditTarget(null);
}}
onSubmit={(id, input) => handleSubmit(id, input)}
/>
) : null}
{deleteTarget ? (
<DeleteConfirmDialog
title={t("deleteConfirm.title")}
message={t("deleteConfirm.message", { name: deleteTarget.name })}
confirmLabel={t("deleteConfirm.confirm")}
cancelLabel={t("deleteConfirm.cancel")}
loading={deleteMutation.loading}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void handleDelete()}
/>
) : null}
{scheduleTarget ? (
<ScheduleManagerDialog
open={scheduleTarget !== null}
classId={scheduleTarget.id}
className={scheduleTarget.name}
onClose={() => setScheduleTarget(null)}
/>
) : null}
{invitationTarget ? (
<ClassInvitationManagerDialog
open={invitationTarget !== null}
classId={invitationTarget.id}
className={invitationTarget.name}
onClose={() => setInvitationTarget(null)}
/>
) : null}
</ListPageShell>
);
}
@@ -164,8 +311,18 @@ export function AdminClassesClient(): React.ReactElement {
*/
function ClassesTable({
items,
teacherNameMap,
onEdit,
onDelete,
onManageSchedule,
onManageInvitation,
}: {
items: AdminClassListItem[];
teacherNameMap: Map<string, string>;
onEdit: (c: AdminClassListItem) => void;
onDelete: (c: AdminClassListItem) => void;
onManageSchedule: (c: AdminClassListItem) => void;
onManageInvitation: (c: AdminClassListItem) => void;
}): React.ReactElement {
const t = useTranslations("admin.school.classes");
return (
@@ -174,30 +331,91 @@ function ClassesTable({
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("colName")}</th>
<th className="p-3 text-left font-medium">
{t("colHomeroomLabel")}
</th>
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
<th className="p-3 text-left font-medium">{t("colSchool")}</th>
<th className="p-3 text-left font-medium">{t("colHeadTeacher")}</th>
<th className="p-3 text-left font-medium">{t("colRoom")}</th>
<th className="p-3 text-left font-medium">
{t("colSubjectTeachers")}
</th>
<th className="p-3 text-right font-medium">
{t("colStudentCount")}
</th>
<th className="p-3 text-right font-medium">
{t("colSubjectCount")}
</th>
<th className="p-3 text-left font-medium">{t("colUpdatedAt")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((c) => (
<tr key={c.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{truncateText(c.name)}</td>
<td className="p-3 text-muted-foreground">
{c.homeroomLabel ?? "--"}
</td>
<td className="p-3 text-muted-foreground">{c.gradeName}</td>
<td className="p-3 text-muted-foreground">{c.schoolName}</td>
<td className="p-3 text-muted-foreground">{c.headTeacherName}</td>
<td className="p-3 text-muted-foreground">
{teacherNameMap.get(c.headTeacherId) ??
c.headTeacherName ??
"-"}
</td>
<td className="p-3 text-muted-foreground">{c.room ?? "--"}</td>
<td className="max-w-xs p-3 text-muted-foreground">
{truncateText(c.subjectTeachers ?? "--", 30)}
</td>
<td className="p-3 text-right font-mono text-xs text-muted-foreground">
{c.studentCount}
</td>
<td className="p-3 text-right font-mono text-xs text-muted-foreground">
{c.subjectCount}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{c.updatedAt ? formatSchoolDate(c.updatedAt) : "--"}
</td>
<td className="p-3 text-right">
<div className="flex flex-wrap justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(c)}
aria-label={t("edit")}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onManageSchedule(c)}
aria-label={t("manageSchedule")}
>
<Calendar className="mr-1 size-4" />
{t("manageSchedule")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onManageInvitation(c)}
aria-label={t("manageInvitation")}
>
<Ticket className="mr-1 size-4" />
{t("manageInvitation")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(c)}
aria-label={t("delete")}
>
<Trash2 className="size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
@@ -205,3 +423,171 @@ function ClassesTable({
</div>
);
}
/**
* 班级新建/编辑对话框。
*/
function ClassFormDialog({
editTarget,
schools,
grades,
teacherOptions,
loading,
onClose,
onSubmit,
}: {
editTarget: AdminClassListItem | null;
schools: SchoolListItem[];
grades: Array<{ id: string; name: string }>;
teacherOptions: Array<{ id: string; name: string }>;
loading: boolean;
onClose: () => void;
onSubmit: (id: string | null, input: AdminClassInput) => Promise<void>;
}): React.ReactElement {
const t = useTranslations("admin.school.classes");
const [name, setName] = useState("");
const [schoolId, setSchoolId] = useState("");
const [gradeId, setGradeId] = useState("");
const [headTeacherId, setHeadTeacherId] = useState("");
const [homeroomLabel, setHomeroomLabel] = useState("");
const [room, setRoom] = useState("");
const [homeroom, setHomeroom] = useState("");
useEffect(() => {
if (editTarget) {
setName(editTarget.name);
setSchoolId(editTarget.schoolId);
setGradeId(editTarget.gradeId);
setHeadTeacherId(editTarget.headTeacherId);
setHomeroomLabel(editTarget.homeroomLabel ?? "");
setRoom(editTarget.room ?? "");
setHomeroom(editTarget.homeroom ?? "");
} else {
setName("");
setSchoolId("");
setGradeId("");
setHeadTeacherId("");
setHomeroomLabel("");
setRoom("");
setHomeroom("");
}
}, [editTarget]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault();
if (!name.trim() || !schoolId || !gradeId) return;
void onSubmit(editTarget?.id ?? null, {
name: name.trim(),
gradeId,
schoolId,
headTeacherId: headTeacherId.trim() || undefined,
homeroomLabel: homeroomLabel.trim() || undefined,
room: room.trim() || undefined,
homeroom: homeroom.trim() || undefined,
});
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<FormField label={t("form.fieldName")} required>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</FormField>
<FormField label={t("form.fieldSchool")} required>
<select
value={schoolId}
onChange={(e) => setSchoolId(e.target.value)}
required
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="">--</option>
{schools.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</FormField>
<FormField label={t("form.fieldGrade")} required>
<select
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
required
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="">--</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</FormField>
<FormField label={t("form.fieldHeadTeacher")}>
<select
value={headTeacherId}
onChange={(e) => setHeadTeacherId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="">--</option>
{teacherOptions.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</FormField>
<FormField label={t("form.fieldHomeroomLabel")}>
<Input
type="text"
value={homeroomLabel}
onChange={(e) => setHomeroomLabel(e.target.value)}
placeholder={t("form.fieldHomeroomLabelPlaceholder")}
/>
</FormField>
<FormField label={t("form.fieldRoom")}>
<Input
type="text"
value={room}
onChange={(e) => setRoom(e.target.value)}
placeholder={t("form.fieldRoomPlaceholder")}
/>
</FormField>
<FormField label={t("form.fieldHomeroom")}>
<select
value={homeroom}
onChange={(e) => setHomeroom(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="">--</option>
{teacherOptions.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</FormField>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("form.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{t("form.submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

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>
);
}

View File

@@ -0,0 +1,509 @@
"use client";
/**
* 班级课表 CRUD 对话框 - 客户端组件ARCHITECTURE.md §7.3 / §9.4 / §10 P5
*
* 数据契约useClassSchedules / useCreateClassSchedule / useUpdateClassSchedule / useDeleteClassSchedule
* ❌ schema 无对应根字段 → MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { useEffect, useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import {
useCreateClassSchedule,
useDeleteClassSchedule,
useClassSchedules,
useUpdateClassSchedule,
type ClassScheduleInput,
type ClassScheduleItem,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { FormField } from "@/features/admin/school/schools-client";
/** 星期 1-7 */
const WEEKDAY_OPTIONS = [1, 2, 3, 4, 5, 6, 7] as const;
/** 节次 1-12 */
const PERIOD_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const;
// ── ScheduleManagerDialog ──────────────────────────────────────
/**
* 班级课表管理对话框。
*
* - 列表查询useClassSchedules(classId)
* - 新增/编辑ScheduleFormDialogmode 切换)
* - 删除ScheduleDeleteDialog
*/
export function ScheduleManagerDialog({
open,
classId,
className,
onClose,
}: {
open: boolean;
classId: string;
className: string;
onClose: () => void;
}): React.ReactElement | null {
const t = useTranslations("admin.school.classes.schedule");
const tCommon = useTranslations("common");
const {
data: schedules,
loading,
error,
refetch,
} = useClassSchedules(classId);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<"create" | "edit">("create");
const [editTarget, setEditTarget] = useState<ClassScheduleItem | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ClassScheduleItem | null>(
null,
);
const handleAdd = (): void => {
setFormMode("create");
setEditTarget(null);
setFormOpen(true);
};
const handleEdit = (item: ClassScheduleItem): void => {
setFormMode("edit");
setEditTarget(item);
setFormOpen(true);
};
const handleSubmitted = (): void => {
void refetch();
};
const handleDeleted = (): 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-4xl 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("manager.title")}</h2>
{className ? (
<p className="mt-1 text-xs text-muted-foreground">
{t("form.classLabel")}: {className}
</p>
) : null}
</div>
<Button size="sm" onClick={handleAdd}>
<Plus className="mr-1.5 size-4" />
{t("manager.add")}
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="py-12 text-center text-sm text-muted-foreground">
{tCommon("status.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>
) : schedules && schedules.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("form.fieldWeekday")}
</th>
<th className="p-3 text-left font-medium">
{t("manager.colPeriod")}
</th>
<th className="p-3 text-left font-medium">
{t("manager.colSubject")}
</th>
<th className="p-3 text-left font-medium">
{t("manager.colTeacher")}
</th>
<th className="p-3 text-left font-medium">
{t("manager.colClassroom")}
</th>
<th className="p-3 text-left font-medium">
{t("manager.colTime")}
</th>
<th className="p-3 text-right font-medium">
{t("manager.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{schedules.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-3">{t(`weekday.${item.weekday}`)}</td>
<td className="p-3">
{t("form.periodN", { n: item.period })}
</td>
<td className="p-3 font-medium">{item.subjectName}</td>
<td className="p-3 text-muted-foreground">
{item.teacherName}
</td>
<td className="p-3 text-muted-foreground">
{item.classroom ?? "-"}
</td>
<td className="p-3 text-xs text-muted-foreground">
{item.startTime} - {item.endTime}
</td>
<td className="p-3 text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item)}
aria-label={tCommon("button.edit")}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(item)}
aria-label={tCommon("button.delete")}
>
<Trash2 className="size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-12 text-center text-sm text-muted-foreground">
{t("manager.empty")}
</div>
)}
</div>
<div className="mt-4 flex justify-end">
<Button variant="outline" onClick={onClose}>
{tCommon("button.cancel")}
</Button>
</div>
<ScheduleFormDialog
open={formOpen}
mode={formMode}
classId={classId}
className={className}
initialData={editTarget}
onClose={() => setFormOpen(false)}
onSubmitted={handleSubmitted}
/>
{deleteTarget ? (
<ScheduleDeleteDialog
target={deleteTarget}
onClose={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
/>
) : null}
</div>
</div>
);
}
// ── ScheduleFormDialog ─────────────────────────────────────────
/**
* 课表条目新增/编辑表单对话框mode 切换)。
*
* 字段:星期 / 节次 / 学科 / 教师 / 教室 / 起止时间
*/
function ScheduleFormDialog({
open,
mode,
classId,
className,
initialData,
onClose,
onSubmitted,
}: {
open: boolean;
mode: "create" | "edit";
classId: string;
className: string;
initialData: ClassScheduleItem | null;
onClose: () => void;
onSubmitted?: (item: ClassScheduleItem) => void;
}): React.ReactElement | null {
const t = useTranslations("admin.school.classes.schedule");
const tCommon = useTranslations("common");
const createMutation = useCreateClassSchedule();
const updateMutation = useUpdateClassSchedule();
const [weekday, setWeekday] = useState<number>(1);
const [period, setPeriod] = useState<number>(1);
const [subjectName, setSubjectName] = useState<string>("");
const [teacherName, setTeacherName] = useState<string>("");
const [classroom, setClassroom] = useState<string>("");
const [startTime, setStartTime] = useState<string>("08:00");
const [endTime, setEndTime] = useState<string>("08:45");
useEffect(() => {
if (!open) return;
if (mode === "edit" && initialData) {
setWeekday(initialData.weekday);
setPeriod(initialData.period);
setSubjectName(initialData.subjectName);
setTeacherName(initialData.teacherName);
setClassroom(initialData.classroom ?? "");
setStartTime(initialData.startTime);
setEndTime(initialData.endTime);
} else {
setWeekday(1);
setPeriod(1);
setSubjectName("");
setTeacherName("");
setClassroom("");
setStartTime("08:00");
setEndTime("08:45");
}
}, [open, mode, initialData]);
const loading = createMutation.loading || updateMutation.loading;
const handleSubmit = async (
e: React.FormEvent<HTMLFormElement>,
): Promise<void> => {
e.preventDefault();
if (!classId) return;
if (!subjectName.trim() || !teacherName.trim()) return;
const input: ClassScheduleInput = {
classId,
weekday,
period,
subjectName: subjectName.trim(),
teacherName: teacherName.trim(),
classroom: classroom.trim() || null,
startTime,
endTime,
};
try {
let result: ClassScheduleItem;
if (mode === "edit" && initialData) {
result = await updateMutation.run(initialData.id, input);
notify.success(t("form.editSuccess"));
} else {
result = await createMutation.run(input);
notify.success(t("form.createSuccess"));
}
onSubmitted?.(result);
onClose();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
if (!open) return null;
const title = mode === "edit" ? t("form.titleEdit") : t("form.titleCreate");
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-4 text-lg font-semibold">{title}</h2>
{className ? (
<p className="mb-4 text-xs text-muted-foreground">
{t("form.classLabel")}: {className}
</p>
) : null}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField label={t("form.fieldWeekday")} required>
<select
value={weekday}
onChange={(e) => setWeekday(Number(e.target.value))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
>
{WEEKDAY_OPTIONS.map((w) => (
<option key={w} value={w}>
{t(`weekday.${w}`)}
</option>
))}
</select>
</FormField>
<FormField label={t("form.fieldPeriod")} required>
<select
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
>
{PERIOD_OPTIONS.map((p) => (
<option key={p} value={p}>
{t("form.periodN", { n: p })}
</option>
))}
</select>
</FormField>
</div>
<FormField label={t("form.fieldSubject")} required>
<Input
value={subjectName}
onChange={(e) => setSubjectName(e.target.value)}
placeholder={t("form.subjectPlaceholder")}
required
/>
</FormField>
<FormField label={t("form.fieldTeacher")} required>
<Input
value={teacherName}
onChange={(e) => setTeacherName(e.target.value)}
placeholder={t("form.teacherPlaceholder")}
required
/>
</FormField>
<FormField label={t("form.fieldClassroom")}>
<Input
value={classroom}
onChange={(e) => setClassroom(e.target.value)}
placeholder={t("form.classroomPlaceholder")}
/>
</FormField>
<div className="grid grid-cols-2 gap-4">
<FormField label={t("form.fieldStartTime")} required>
<Input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</FormField>
<FormField label={t("form.fieldEndTime")} required>
<Input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
required
/>
</FormField>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={loading}
>
{tCommon("button.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{mode === "edit" ? t("form.save") : t("form.create")}
</Button>
</div>
</form>
</div>
</div>
);
}
// ── ScheduleDeleteDialog ───────────────────────────────────────
/**
* 课表条目删除确认对话框。
*/
function ScheduleDeleteDialog({
target,
onClose,
onDeleted,
}: {
target: ClassScheduleItem;
onClose: () => void;
onDeleted?: () => void;
}): React.ReactElement {
const t = useTranslations("admin.school.classes.schedule");
const tCommon = useTranslations("common");
const deleteMutation = useDeleteClassSchedule();
const handleDelete = async (): Promise<void> => {
try {
await deleteMutation.run(target.id);
notify.success(t("form.deleteSuccess"));
onDeleted?.();
onClose();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<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("form.deleteTitle")}</h2>
<p className="mb-4 text-sm text-muted-foreground">
{t("form.deleteMessage", {
weekday: t(`weekday.${target.weekday}`),
period: target.period,
subject: target.subjectName,
})}
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={deleteMutation.loading}
>
{tCommon("button.cancel")}
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteMutation.loading}
onClick={() => void handleDelete()}
>
{t("form.deleteConfirm")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -37,7 +37,10 @@ import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { truncateText } from "@/features/admin/school/transformations";
import {
formatSchoolDate,
truncateText,
} from "@/features/admin/school/transformations";
import {
DeleteConfirmDialog,
FormField,
@@ -267,9 +270,11 @@ function DepartmentsTable({
<th className="p-3 text-left font-medium">{t("colName")}</th>
<th className="p-3 text-left font-medium">{t("colSchool")}</th>
<th className="p-3 text-left font-medium">{t("colHead")}</th>
<th className="p-3 text-left font-medium">{t("colDescription")}</th>
<th className="p-3 text-right font-medium">
{t("colMemberCount")}
</th>
<th className="p-3 text-left font-medium">{t("colUpdatedAt")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr>
</thead>
@@ -281,9 +286,15 @@ function DepartmentsTable({
{schoolNameMap.get(d.schoolId) ?? d.schoolId}
</td>
<td className="p-3 text-muted-foreground">{d.headName}</td>
<td className="max-w-xs p-3 text-muted-foreground">
{truncateText(d.description ?? "--", 30)}
</td>
<td className="p-3 text-right font-mono text-xs text-muted-foreground">
{d.memberCount}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatSchoolDate(d.updatedAt)}
</td>
<td className="p-3 text-right">
<div className="flex justify-end gap-1">
<Button
@@ -332,16 +343,19 @@ function DepartmentFormDialog({
const [name, setName] = useState("");
const [schoolId, setSchoolId] = useState("");
const [headId, setHeadId] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
if (editTarget) {
setName(editTarget.name);
setSchoolId(editTarget.schoolId);
setHeadId(editTarget.headId);
setDescription(editTarget.description ?? "");
} else {
setName("");
setSchoolId("");
setHeadId("");
setDescription("");
}
}, [editTarget]);
@@ -352,6 +366,7 @@ function DepartmentFormDialog({
name: name.trim(),
schoolId,
headId: headId.trim() || undefined,
description: description.trim() || undefined,
});
};
@@ -398,6 +413,13 @@ function DepartmentFormDialog({
onChange={(e) => setHeadId(e.target.value)}
/>
</FormField>
<FormField label={t("form.fieldDescription")}>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>
{t("form.cancel")}

View File

@@ -0,0 +1,495 @@
"use client";
/**
* 年级洞察页 - 客户端组件ARCHITECTURE.md §7.3 / §9.4 管理域 / §10 P5
*
* 数据契约:
* - 全校年级成绩汇总 schoolWideGradeSummary():❌ schema 无 → MSW 兜底(@contract-pending
* - 年级列表 grades():❌ schema 无 → MSW 兜底(@contract-pending用于 ChipNav 年级筛选
*
* URL 状态:?gradeId=(空或 "all" 表示全部)
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 页面结构:
* 1. 全校汇总统计卡片4 个 StatCard平均分 / 及格率 / 优秀率 / 参考人数)
* 2. 年级筛选ChipNavURL 驱动,无整页刷新)
* 3. 最近作业表recentAssignments[],含提交率徽章)
* 4. 班级排名表(合并 grades[].classRankings含 delta 涨跌徽章)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import {
Award,
BarChart3,
CheckCircle2,
TrendingDown,
TrendingUp,
Users,
} from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useGrades, useSchoolWideGradeSummary } from "@/lib/api";
import type { SchoolWideOverallStats } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { ChipNav } from "@/shared/components/ui/chip-nav";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { StatCard } from "@/shared/components/ui/stat-card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatCount,
formatSchoolDate,
formatScore,
} from "@/features/admin/school/transformations";
/**
* 年级洞察客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function GradeInsightsClient(): React.ReactElement {
const t = useTranslations("admin.school.grades.insights");
const tCommon = useTranslations("common");
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const gradeIdParam = searchParams.get("gradeId") ?? "";
const selectedGradeId =
gradeIdParam && gradeIdParam !== "all" ? gradeIdParam : "";
// @contract-pendingMSW 兜底,用于 ChipNav 年级选项
const { data: gradeOptions } = useGrades();
// @contract-pendingMSW 兜底,用于全校汇总 + 班级排名 + 最近作业
const { data, loading, error, refetch } =
useSchoolWideGradeSummary(selectedGradeId);
// 错误降级query 出错时弹出通知§11.3 DoDcatch 块必须包含 notify.error
useEffect(() => {
if (error) {
notify.error(
tCommon("error.loadFailed", {
message: error instanceof Error ? error.message : String(error),
}),
);
}
}, [error, tCommon]);
const chipNavOptions = useMemo(
() =>
(gradeOptions ?? []).map((g) => ({
id: g.id,
name: g.name,
})),
[gradeOptions],
);
const buildHref = (gId: string): string => {
if (!gId || gId === "all") {
return "/shell/admin/school/grades/insights";
}
return `/shell/admin/school/grades/insights?gradeId=${encodeURIComponent(gId)}`;
};
const handleRetry = (): void => {
startTransition(() => {
void refetch();
});
};
// 合并各年级的班级排名(保留所属年级信息)
const classRankings = useMemo(() => {
if (!data?.grades?.length) return [];
const merged: Array<{
gradeName: string;
classId: string;
className: string;
averageScore: number;
passRate: number;
studentCount: number;
rank: number;
delta: number;
}> = [];
for (const g of data.grades) {
for (const c of g.classRankings ?? []) {
merged.push({
gradeName: g.gradeName,
classId: c.classId,
className: c.className,
averageScore: c.averageScore,
passRate: c.passRate,
studentCount: c.studentCount,
rank: c.rank,
delta: c.delta,
});
}
}
return merged;
}, [data]);
// 加载态:复用 ListPageSkeleton
if (loading && !data) {
return <ListPageSkeleton rows={6} />;
}
// 错误态:局部降级 EmptyState + 重试按钮
if (error && !data) {
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<BarChart3 className="size-5" aria-hidden="true" />}
actions={
<Button asChild variant="outline">
<Link href="/shell/admin/school/grades">{t("manageGrades")}</Link>
</Button>
}
>
<EmptyState
icon={BarChart3}
title={t("error.title")}
description={t("error.unknown")}
action={{
label: tCommon("button.retry"),
onClick: handleRetry,
variant: "outline",
}}
/>
</ListPageShell>
);
}
const overall = data?.overallStats;
const assignments = data?.recentAssignments ?? [];
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<BarChart3 className="size-5" aria-hidden="true" />}
actions={
<Button asChild variant="outline">
<Link href="/shell/admin/school/grades">{t("manageGrades")}</Link>
</Button>
}
filters={
<ChipNav
options={chipNavOptions}
currentId={selectedGradeId || "all"}
buildHref={buildHref}
allOption={{ id: "all", label: t("allGrades") }}
ariaLabel={t("filterByGrade")}
/>
}
>
<div className="flex flex-col gap-6">
{/* 1. 全校汇总统计卡片 */}
<SchoolWideSummaryCard overall={overall} />
{/* 2. 最近作业表 */}
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">
{t("assignments.title")}
</CardTitle>
<CardDescription>{t("assignments.description")}</CardDescription>
</div>
<Badge variant="secondary" className="tabular-nums">
{formatCount(assignments.length)}
</Badge>
</CardHeader>
<CardContent>
{assignments.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("assignments.empty")}
</p>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t("assignments.colTitle")}</TableHead>
<TableHead>{t("assignments.colSubject")}</TableHead>
<TableHead>{t("assignments.colGrade")}</TableHead>
<TableHead className="text-right">
{t("assignments.colAverage")}
</TableHead>
<TableHead className="text-right">
{t("assignments.colSubmitCount")}
</TableHead>
<TableHead>{t("assignments.colStatus")}</TableHead>
<TableHead>{t("assignments.colCreatedAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((a) => {
const submitRate =
a.totalStudents > 0
? (a.submitCount / a.totalStudents) * 100
: 0;
return (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">
{a.title}
</TableCell>
<TableCell>{a.subjectName}</TableCell>
<TableCell className="text-muted-foreground">
{a.gradeName}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatScore(a.averageScore)}
</TableCell>
<TableCell className="text-right tabular-nums">
<span>{formatCount(a.submitCount)}</span>
<span className="text-muted-foreground">
{" "}
/ {formatCount(a.totalStudents)}
</span>
<Badge
variant={
submitRate >= 95
? "default"
: submitRate >= 80
? "secondary"
: "outline"
}
className="ml-2 tabular-nums"
>
{formatScore(submitRate)}%
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
a.status === "graded" ? "secondary" : "outline"
}
className="capitalize"
>
{assignmentStatusToLabel(a.status, t)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{formatSchoolDate(a.createdAt)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* 3. 班级排名表 */}
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">
{t("classRanking.title")}
</CardTitle>
<CardDescription>{t("classRanking.description")}</CardDescription>
</div>
<Badge variant="secondary" className="tabular-nums">
{formatCount(classRankings.length)}
</Badge>
</CardHeader>
<CardContent>
{classRankings.length === 0 ? (
<EmptyState
icon={BarChart3}
title={t("classRanking.empty")}
description={t("classRanking.emptyDescription")}
className="min-h-[240px] bg-transparent"
/>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t("classRanking.colGrade")}</TableHead>
<TableHead>{t("classRanking.colClass")}</TableHead>
<TableHead className="text-right">
{t("classRanking.colRank")}
</TableHead>
<TableHead className="text-right">
{t("classRanking.colAverage")}
</TableHead>
<TableHead className="text-right">
{t("classRanking.colPassRate")}
</TableHead>
<TableHead className="text-right">
{t("classRanking.colStudentCount")}
</TableHead>
<TableHead className="text-right">
{t("classRanking.colDelta")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{classRankings.map((c) => {
const isUp = c.delta > 0;
const isDown = c.delta < 0;
const DeltaIcon = isUp
? TrendingUp
: isDown
? TrendingDown
: null;
return (
<TableRow key={c.classId}>
<TableCell className="text-muted-foreground">
{c.gradeName}
</TableCell>
<TableCell className="font-medium">
{c.className}
</TableCell>
<TableCell className="text-right tabular-nums">
<Badge
variant={c.rank === 1 ? "default" : "secondary"}
className="tabular-nums"
>
#{c.rank}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatScore(c.averageScore)}
</TableCell>
<TableCell className="text-right tabular-nums">
<Badge
variant={
c.passRate >= 90
? "default"
: c.passRate >= 75
? "secondary"
: "outline"
}
className="tabular-nums"
>
{formatScore(c.passRate)}%
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCount(c.studentCount)}
</TableCell>
<TableCell className="text-right tabular-nums">
{DeltaIcon ? (
<span
className={
isUp
? "inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400"
: "inline-flex items-center gap-1 text-rose-600 dark:text-rose-400"
}
>
<DeltaIcon
className="size-3"
aria-hidden="true"
/>
{isUp ? "+" : ""}
{formatScore(c.delta)}
</span>
) : (
<span className="text-muted-foreground">0.0</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</ListPageShell>
);
}
/**
* 全校汇总卡片(对齐 CICD SchoolWideSummaryCard
*
* 由 4 张 StatCard 组成:平均分 / 及格率 / 优秀率 / 参考人数。
* 入参 overall 为空时各指标回退占位符§11.3 DoD 三态规范)。
*/
function SchoolWideSummaryCard({
overall,
}: {
overall: SchoolWideOverallStats | null | undefined;
}): React.ReactElement {
const t = useTranslations("admin.school.grades.insights");
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title={t("stats.averageScore")}
value={formatScore(overall?.averageScore)}
icon={TrendingUp}
description={t("stats.averageScoreHint")}
valueClassName="tabular-nums"
/>
<StatCard
title={t("stats.passRate")}
value={`${formatScore(overall?.passRate)}%`}
icon={CheckCircle2}
description={t("stats.passRateHint")}
valueClassName="tabular-nums"
/>
<StatCard
title={t("stats.excellenceRate")}
value={`${formatScore(overall?.excellenceRate)}%`}
icon={Award}
description={t("stats.excellenceRateHint")}
valueClassName="tabular-nums"
/>
<StatCard
title={t("stats.totalParticipants")}
value={formatCount(overall?.totalParticipants)}
icon={Users}
description={t("stats.totalParticipantsHint")}
valueClassName="tabular-nums"
/>
</div>
);
}
/**
* 作业状态 → i18n 标签。
* 已知状态走 i18n key未知状态回退为原始字符串避免 next-intl 抛 MISSING_MESSAGE 错误)。
*/
function assignmentStatusToLabel(
status: string,
t: ReturnType<typeof useTranslations>,
): string {
switch (status) {
case "graded":
return t("assignments.status.graded");
case "submitted":
return t("assignments.status.submitted");
case "pending":
return t("assignments.status.pending");
case "draft":
return t("assignments.status.draft");
default:
return status;
}
}

View File

@@ -14,10 +14,18 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { GraduationCap, Pencil, Plus, Trash2, Users } from "lucide-react";
import {
BarChart3,
GraduationCap,
Pencil,
Plus,
Trash2,
Users,
} from "lucide-react";
import { useSearchParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import Link from "next/link";
// 直接从 admin-p5 导入,避免与 ./grades 的 GradeListItem/useCreateGrade 命名冲突
import {
@@ -26,14 +34,24 @@ import {
useGradeOverviewStats,
useGrades,
useSchools,
useStaffOptions,
useUpdateGrade,
type AdminGradeListItem,
type GradeInput,
type GradeOverviewStat,
type OptionItem,
type SchoolListItem,
} from "@/lib/api/admin-p5";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Input } from "@/shared/components/ui/input";
@@ -70,6 +88,8 @@ export function GradesClient(): React.ReactElement {
// @contract-pendingMSW 兜底
const { data: stats, loading: statsLoading } = useGradeOverviewStats();
const { data: schools } = useSchools();
// @contract-pendingMSW 兜底
const { data: staffOptions } = useStaffOptions();
const createMutation = useAdminCreateGrade();
const updateMutation = useUpdateGrade();
const deleteMutation = useDeleteGrade();
@@ -88,6 +108,14 @@ export function GradesClient(): React.ReactElement {
return map;
}, [schools]);
const statsMap = useMemo<Map<string, GradeOverviewStat>>(() => {
const map = new Map<string, GradeOverviewStat>();
for (const s of stats ?? []) {
map.set(s.gradeId, s);
}
return map;
}, [stats]);
const filteredItems = useMemo<AdminGradeListItem[]>(() => {
const items = data ?? [];
return items.filter((g) => {
@@ -178,15 +206,23 @@ export function GradesClient(): React.ReactElement {
description={t("description")}
icon={<GraduationCap className="size-6" />}
actions={
<Button
onClick={() => {
setEditTarget(null);
setFormOpen(true);
}}
>
<Plus className="mr-1 size-4" />
{t("createButton")}
</Button>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href="/shell/admin/school/grades/insights">
<BarChart3 className="mr-1 size-4" />
{t("insights")}
</Link>
</Button>
<Button
onClick={() => {
setEditTarget(null);
setFormOpen(true);
}}
>
<Plus className="mr-1 size-4" />
{t("createButton")}
</Button>
</div>
}
filters={
<>
@@ -223,6 +259,15 @@ export function GradesClient(): React.ReactElement {
>
<div className="flex flex-col gap-6">
<GradesStatsCards stats={stats} loading={statsLoading} />
<GradeOverviewCards
items={filteredItems}
statsMap={statsMap}
onEdit={(g) => {
setEditTarget(g);
setFormOpen(true);
}}
onDelete={(g) => setDeleteTarget(g)}
/>
<GradesTable
items={filteredItems}
schoolNameMap={schoolNameMap}
@@ -237,6 +282,7 @@ export function GradesClient(): React.ReactElement {
<GradeFormDialog
editTarget={editTarget}
schools={schools ?? []}
staffOptions={staffOptions ?? []}
loading={createMutation.loading || updateMutation.loading}
onClose={() => {
setFormOpen(false);
@@ -324,6 +370,143 @@ function GradesStatsCards({
);
}
/**
* 年级概览卡片视图(对齐 CICD GradeOverviewCards
*
* 以卡片网格展示前 8 个年级,每张卡片包含年级名称、所属学校、
* 班级/学生统计、平均分、年级组长以及"查看洞察"快捷入口。
*/
function GradeOverviewCards({
items,
statsMap,
onEdit,
onDelete,
}: {
items: AdminGradeListItem[];
statsMap: Map<string, GradeOverviewStat>;
onEdit: (grade: AdminGradeListItem) => void;
onDelete: (grade: AdminGradeListItem) => void;
}): React.ReactElement | null {
const t = useTranslations("admin.school.grades");
if (items.length === 0) return null;
return (
<section aria-label={t("gradeOverviewSection")}>
<h3 className="mb-3 text-sm font-medium text-muted-foreground">
{t("gradeOverviewSection")}
</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{items.slice(0, 8).map((g) => {
const stats = statsMap.get(g.id);
const schoolName = g.schoolName || t("notSet");
return (
<Card key={g.id} className="shadow-none">
<CardContent className="space-y-3 p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">
{truncateText(g.name)}
</div>
<div className="truncate text-xs text-muted-foreground">
{schoolName}
</div>
</div>
<div className="flex shrink-0 gap-1">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => onEdit(g)}
aria-label={t("edit")}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => onDelete(g)}
aria-label={t("delete")}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<GraduationCap className="size-3" aria-hidden="true" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{formatCount(stats?.classCount ?? g.classCount)}
</div>
<div className="text-[10px] text-muted-foreground">
{t("statsClassCount")}
</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<Users className="size-3" aria-hidden="true" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{formatCount(stats?.studentCount ?? g.studentCount)}
</div>
<div className="text-[10px] text-muted-foreground">
{t("statsStudentCount")}
</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<BarChart3 className="size-3" aria-hidden="true" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{formatScore(stats?.avgScore ?? null)}
</div>
<div className="text-[10px] text-muted-foreground">
{t("statsAvgScore")}
</div>
</div>
</div>
<div className="space-y-1 border-t pt-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("colHeadStaff")}
</span>
<span className="truncate font-medium">
{g.headStaffName || t("notSet")}
</span>
</div>
{g.teachingHeadName ? (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("colTeachingHead")}
</span>
<span className="truncate font-medium">
{g.teachingHeadName}
</span>
</div>
) : null}
</div>
<Button asChild variant="outline" size="sm" className="w-full">
<Link
href={`/shell/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`}
>
<BarChart3 className="mr-1.5 size-3.5" />
{t("insights")}
</Link>
</Button>
</CardContent>
</Card>
);
})}
</div>
</section>
);
}
/**
* 年级列表表格。
*/
@@ -402,12 +585,14 @@ function GradesTable({
function GradeFormDialog({
editTarget,
schools,
staffOptions,
loading,
onClose,
onSubmit,
}: {
editTarget: AdminGradeListItem | null;
schools: SchoolListItem[];
staffOptions: OptionItem[];
loading: boolean;
onClose: () => void;
onSubmit: (id: string | null, input: GradeInput) => Promise<void>;
@@ -440,17 +625,13 @@ function GradeFormDialog({
};
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-4 text-lg font-semibold">
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
</h2>
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<FormField label={t("form.fieldName")} required>
<Input
@@ -476,22 +657,29 @@ function GradeFormDialog({
</select>
</FormField>
<FormField label={t("form.fieldHeadStaff")}>
<Input
type="text"
<select
value={headStaffId}
onChange={(e) => setHeadStaffId(e.target.value)}
/>
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="">--</option>
{staffOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</FormField>
<div className="flex justify-end gap-2">
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("form.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{t("form.submit")}
</Button>
</div>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -13,12 +13,13 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { Building2, Plus, Trash2 } from "lucide-react";
import { Building2, Pencil, Plus, Trash2 } from "lucide-react";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import {
useAdminUpdateSchool,
useCreateSchool,
useDeleteSchool,
useSchools,
@@ -26,7 +27,24 @@ import {
type SchoolInput,
} 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 { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Input } from "@/shared/components/ui/input";
@@ -34,7 +52,10 @@ import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { truncateText } from "@/features/admin/school/transformations";
import {
formatSchoolDate,
truncateText,
} from "@/features/admin/school/transformations";
/**
* 学校列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -52,9 +73,11 @@ export function SchoolsClient(): React.ReactElement {
// @contract-pendingMSW 兜底
const { data, loading, error, refetch } = useSchools();
const createMutation = useCreateSchool();
const updateMutation = useAdminUpdateSchool();
const deleteMutation = useDeleteSchool();
const [createOpen, setCreateOpen] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [editTarget, setEditTarget] = useState<SchoolListItem | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SchoolListItem | null>(null);
const filteredItems = useMemo<SchoolListItem[]>(() => {
@@ -80,11 +103,20 @@ export function SchoolsClient(): React.ReactElement {
});
};
const handleCreated = async (): Promise<void> => {
const handleSubmit = async (
id: string | null,
input: SchoolInput,
): Promise<void> => {
try {
if (id) {
await updateMutation.run(id, input);
} else {
await createMutation.run(input);
}
await refetch();
notify.success(t("form.titleCreate"));
setCreateOpen(false);
notify.success(id ? t("form.titleEdit") : t("form.titleCreate"));
setFormOpen(false);
setEditTarget(null);
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
@@ -118,7 +150,10 @@ export function SchoolsClient(): React.ReactElement {
description={t("emptyDescription")}
action={{
label: t("emptyAction"),
onClick: () => setCreateOpen(true),
onClick: () => {
setEditTarget(null);
setFormOpen(true);
},
}}
/>
);
@@ -129,7 +164,12 @@ export function SchoolsClient(): React.ReactElement {
description={t("description")}
icon={<Building2 className="size-6" />}
actions={
<Button onClick={() => setCreateOpen(true)}>
<Button
onClick={() => {
setEditTarget(null);
setFormOpen(true);
}}
>
<Plus className="mr-1 size-4" />
{t("createButton")}
</Button>
@@ -154,22 +194,21 @@ export function SchoolsClient(): React.ReactElement {
>
<SchoolsTable
items={filteredItems}
onEdit={(s) => {
setEditTarget(s);
setFormOpen(true);
}}
onDelete={(s) => setDeleteTarget(s)}
/>
{createOpen ? (
<CreateSchoolDialog
loading={createMutation.loading}
onClose={() => setCreateOpen(false)}
onSubmit={async (input) => {
try {
await createMutation.run(input);
await handleCreated();
} catch (err) {
notify.error(
tCommon("error.loadFailed", { message: String(err) }),
);
}
{formOpen ? (
<SchoolFormDialog
editTarget={editTarget}
loading={createMutation.loading || updateMutation.loading}
onClose={() => {
setFormOpen(false);
setEditTarget(null);
}}
onSubmit={(id, input) => handleSubmit(id, input)}
/>
) : null}
{deleteTarget ? (
@@ -192,9 +231,11 @@ export function SchoolsClient(): React.ReactElement {
*/
function SchoolsTable({
items,
onEdit,
onDelete,
}: {
items: SchoolListItem[];
onEdit: (school: SchoolListItem) => void;
onDelete: (school: SchoolListItem) => void;
}): React.ReactElement {
const t = useTranslations("admin.school.schools");
@@ -204,11 +245,13 @@ function SchoolsTable({
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("colName")}</th>
<th className="p-3 text-left font-medium">{t("colCode")}</th>
<th className="p-3 text-left font-medium">{t("colAddress")}</th>
<th className="p-3 text-left font-medium">{t("colPhone")}</th>
<th className="p-3 text-left font-medium">{t("colEmail")}</th>
<th className="p-3 text-left font-medium">{t("colCurrentYear")}</th>
<th className="p-3 text-left font-medium">{t("colCurrentTerm")}</th>
<th className="p-3 text-left font-medium">{t("colUpdatedAt")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr>
</thead>
@@ -216,6 +259,9 @@ function SchoolsTable({
{items.map((s) => (
<tr key={s.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{truncateText(s.name)}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{s.code ?? "--"}
</td>
<td className="max-w-xs p-3 text-muted-foreground">
{truncateText(s.address, 20)}
</td>
@@ -229,15 +275,28 @@ function SchoolsTable({
{s.currentAcademicYear}
</td>
<td className="p-3 text-muted-foreground">{s.currentTerm}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatSchoolDate(s.updatedAt)}
</td>
<td className="p-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(s)}
aria-label={t("delete")}
>
<Trash2 className="size-4" />
</Button>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(s)}
aria-label={t("edit")}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(s)}
aria-label={t("delete")}
>
<Trash2 className="size-4" />
</Button>
</div>
</td>
</tr>
))}
@@ -248,30 +307,55 @@ function SchoolsTable({
}
/**
* 新建学校对话框(轻量模态,无额外依赖)。
* 学校新建/编辑对话框(轻量模态,无额外依赖)。
* editTarget 为 null 时新建,非 null 时编辑并回填字段。
*/
function CreateSchoolDialog({
function SchoolFormDialog({
editTarget,
loading,
onClose,
onSubmit,
}: {
editTarget: SchoolListItem | null;
loading: boolean;
onClose: () => void;
onSubmit: (input: SchoolInput) => Promise<void>;
onSubmit: (id: string | null, input: SchoolInput) => Promise<void>;
}): React.ReactElement {
const t = useTranslations("admin.school.schools");
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [address, setAddress] = useState("");
const [phone, setPhone] = useState("");
const [email, setEmail] = useState("");
const [currentAcademicYear, setCurrentAcademicYear] = useState("");
const [currentTerm, setCurrentTerm] = useState("");
useEffect(() => {
if (editTarget) {
setName(editTarget.name);
setCode(editTarget.code ?? "");
setAddress(editTarget.address);
setPhone(editTarget.phone);
setEmail(editTarget.email);
setCurrentAcademicYear(editTarget.currentAcademicYear);
setCurrentTerm(editTarget.currentTerm);
} else {
setName("");
setCode("");
setAddress("");
setPhone("");
setEmail("");
setCurrentAcademicYear("");
setCurrentTerm("");
}
}, [editTarget]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault();
if (!name.trim()) return;
void onSubmit({
void onSubmit(editTarget?.id ?? null, {
name: name.trim(),
code: code.trim() || undefined,
address: address.trim() || undefined,
phone: phone.trim() || undefined,
email: email.trim() || undefined,
@@ -281,15 +365,13 @@ function CreateSchoolDialog({
};
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-4 text-lg font-semibold">{t("form.titleCreate")}</h2>
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<FormField label={t("form.fieldName")} required>
<Input
@@ -299,6 +381,14 @@ function CreateSchoolDialog({
required
/>
</FormField>
<FormField label={t("form.fieldCode")}>
<Input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="SCH-XXX"
/>
</FormField>
<FormField label={t("form.fieldAddress")}>
<Input
type="text"
@@ -336,22 +426,23 @@ function CreateSchoolDialog({
placeholder="1"
/>
</FormField>
<div className="flex justify-end gap-2">
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("form.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{t("form.submit")}
</Button>
</div>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}
/**
* 删除确认对话框(通用,复用于各子页面)。
* 基于 shadcn AlertDialog§7.3 列表页模板 / §11.3 DoD
*/
export function DeleteConfirmDialog({
title,
@@ -370,32 +461,27 @@ export function DeleteConfirmDialog({
onCancel: () => void;
onConfirm: () => void;
}): React.ReactElement {
const handleConfirm = (e: React.MouseEvent): void => {
e.preventDefault();
onConfirm();
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onCancel}
>
<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">{title}</h2>
<p className="mb-4 text-sm text-muted-foreground">{message}</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
<AlertDialog open onOpenChange={onCancel}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{message}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading} onClick={onCancel}>
{cancelLabel}
</Button>
<Button
type="button"
variant="destructive"
disabled={loading}
onClick={onConfirm}
>
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} disabled={loading}>
{confirmLabel}
</Button>
</div>
</div>
</div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -14,7 +14,6 @@
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { GraduationCap } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
@@ -25,12 +24,14 @@ import {
useGrades,
} from "@/lib/api/admin-p5";
import type { AdminStudent } from "@/lib/api/admin-p5";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
formatStudentDate,
formatStudentStatus,
@@ -272,12 +273,13 @@ function StudentsTable({
{formatStudentDate(s.enrolledAt)}
</td>
<td className="p-3">
<Link
href={`/shell/admin/students/${s.id}`}
className="text-sm text-primary hover:underline"
<Button
variant="ghost"
size="sm"
onClick={() => notify.info(t("list.mswNotice"))}
>
{t("list.viewDetail")}
</Link>
</Button>
</td>
</tr>
))}

View File

@@ -14,19 +14,20 @@
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { School } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useAdminTeachers, useDepartments } from "@/lib/api/admin-p5";
import type { AdminTeacher } from "@/lib/api/admin-p5";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
formatTeacherStatus,
matchTeacherSearch,
@@ -242,12 +243,13 @@ function TeachersTable({
</td>
<td className="p-3 text-muted-foreground">{tc.classCount}</td>
<td className="p-3">
<Link
href={`/shell/admin/teachers/${tc.id}`}
className="text-sm text-primary hover:underline"
<Button
variant="ghost"
size="sm"
onClick={() => notify.info(t("list.mswNotice"))}
>
{t("list.viewDetail")}
</Link>
</Button>
</td>
</tr>
))}

View File

@@ -0,0 +1,227 @@
"use client";
/**
* 用户多角色分配对话框ARCHITECTURE.md §9.4 B5 / §11.3
*
* 数据契约:
* - mutation assignUserRoles(userId, roleNames) ❌ schema 无 → MSW 兜底(@contract-pending
* - 替换语义:传入完整角色名数组,覆盖原有角色
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
* 对齐 CICDsrc/modules/rbac/components/user-role-assign-dialog.tsx
*/
import { UserCog } from "lucide-react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAssignUserRoles } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { notify } from "@/shared/lib/notify";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
/** 可分配角色条目(与 roles 列表对齐) */
export interface AssignableRole {
id: string;
name: string;
description: string;
isSystem: boolean;
isEnabled: boolean;
}
export interface UserRoleAssignDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userId: string;
userName: string;
userEmail: string;
/** 所有可用角色(仅 isEnabled=true 的可分配) */
allRoles: AssignableRole[];
/** 当前用户已分配的角色名列表 */
currentRoleNames: string[];
}
/**
* 多角色分配对话框。展示所有启用角色的复选框列表,
* 保存时按替换语义覆盖原有角色。
*/
export function UserRoleAssignDialog({
open,
onOpenChange,
userId,
userName,
userEmail,
allRoles,
currentRoleNames,
}: UserRoleAssignDialogProps): React.ReactElement | null {
const t = useTranslations("admin.users.assignDialog");
const tCommon = useTranslations("common");
const router = useRouter();
const assignRoles = useAssignUserRoles();
const [selected, setSelected] = useState<Set<string>>(
new Set(currentRoleNames),
);
// 打开时同步当前角色
useEffect(() => {
if (open) {
setSelected(new Set(currentRoleNames));
}
}, [open, currentRoleNames]);
const handleToggle = (roleName: string, checked: boolean): void => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) {
next.add(roleName);
} else {
next.delete(roleName);
}
return next;
});
};
const handleSave = async (): Promise<void> => {
try {
await assignRoles.run(userId, Array.from(selected));
notify.success(t("success"));
onOpenChange(false);
router.refresh();
} catch (err) {
notify.error(tCommon("error.operationFailed", { message: String(err) }));
}
};
// 仅启用的角色可分配;禁用但已分配的展示在底部
const enabledRoles = allRoles.filter((r) => r.isEnabled);
const disabledAssignedRoles = allRoles.filter(
(r) => !r.isEnabled && selected.has(r.name),
);
if (!open) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCog className="size-5" />
{t("title")}
</DialogTitle>
<DialogDescription>
{t("description", { name: userName || userEmail })}
</DialogDescription>
</DialogHeader>
{selected.has("admin") ? (
<Alert className="border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-400">
<AlertDescription>
<strong className="font-medium">{t("adminWarningTitle")}</strong>
{t("adminWarning")}
</AlertDescription>
</Alert>
) : null}
<div
className="max-h-96 space-y-3 overflow-y-auto"
role="group"
aria-label={t("listAriaLabel", { name: userName || userEmail })}
>
{enabledRoles.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{t("noEnabledRoles")}
</p>
) : null}
{enabledRoles.map((role) => {
const isChecked = selected.has(role.name);
const isAdmin = role.name === "admin";
return (
<label
key={role.id}
htmlFor={`role-${role.id}`}
className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent"
>
<input
id={`role-${role.id}`}
type="checkbox"
checked={isChecked}
onChange={(e) => handleToggle(role.name, e.target.checked)}
disabled={assignRoles.loading}
className="mt-0.5 size-4 rounded border-input accent-primary"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{role.name}</span>
{role.isSystem ? (
<span className="inline-flex h-5 items-center rounded-full bg-muted px-2 text-xs">
{t("system")}
</span>
) : null}
{isAdmin ? (
<span className="inline-flex h-5 items-center rounded-full bg-destructive/15 px-2 text-xs text-destructive">
{t("locked")}
</span>
) : null}
</div>
{role.description ? (
<p className="text-xs text-muted-foreground">
{role.description}
</p>
) : null}
</div>
</label>
);
})}
{disabledAssignedRoles.length > 0 ? (
<div className="space-y-1 border-t pt-2">
<p className="text-xs text-muted-foreground">
{t("disabledAssignedLabel")}
</p>
{disabledAssignedRoles.map((role) => (
<div key={role.id} className="flex items-center gap-2 text-xs">
<span className="inline-flex h-5 items-center rounded-full border px-2">
{role.name}
</span>
<span className="text-muted-foreground">
{t("disabledLabel")}
</span>
</div>
))}
</div>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={assignRoles.loading}
>
{tCommon("button.cancel")}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={assignRoles.loading}
>
{assignRoles.loading ? t("saving") : t("save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,7 +11,14 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { CheckCircle2, Download, Upload, XCircle } from "lucide-react";
import {
CheckCircle2,
Download,
Loader2,
RotateCcw,
Upload,
XCircle,
} from "lucide-react";
import { useState } from "react";
import { useTranslations } from "next-intl";
@@ -50,6 +57,9 @@ const TEMPLATE_HEADERS = [
"inviteCode",
] as const;
/** 预览最大行数 */
const PREVIEW_MAX_ROWS = 50;
/**
* 生成并下载 CSV 模板(纯客户端,无契约依赖)。
*/
@@ -66,6 +76,28 @@ function downloadTemplate(): void {
URL.revokeObjectURL(url);
}
/**
* 解析文件文本为预览行(仅前 PREVIEW_MAX_ROWS 行)。
* 支持 CSV 简易分割Excel 二进制文件回退为按行文本展示。
*/
function parsePreviewRows(file: File): Promise<string[][]> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result ?? "");
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "");
const rows = lines.slice(0, PREVIEW_MAX_ROWS).map((line) =>
// 简易 CSV 分割,不处理引号嵌套(预览用途足够)
line.split(",").map((cell) => cell.trim()),
);
resolve(rows);
};
reader.onerror = () => resolve([]);
// Excel 文件读取为文本会有乱码,但仅用于展示前几行结构
reader.readAsText(file);
});
}
/**
* 导入客户端主体。
*/
@@ -74,6 +106,8 @@ export function UsersImportClient(): React.ReactElement {
const { run, loading } = useImportUsers();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [result, setResult] = useState<ImportResult | null>(null);
const [previewRows, setPreviewRows] = useState<string[][]>([]);
const [importing, setImporting] = useState(false);
const handleDownload = (): void => {
try {
@@ -89,19 +123,43 @@ export function UsersImportClient(): React.ReactElement {
const file = e.target.files?.[0];
if (!file) return;
setSelectedFile(file);
setResult(null);
try {
const r = await run(file);
setResult(r);
notify.success(t("imported"));
} catch (e) {
notify.error(String(e));
const rows = await parsePreviewRows(file);
setPreviewRows(rows);
} catch {
setPreviewRows([]);
} finally {
// 允许重复选择同一文件触发 onChange
e.target.value = "";
}
};
const handleReselect = (): void => {
setSelectedFile(null);
setResult(null);
setPreviewRows([]);
};
const handleConfirmImport = async (): Promise<void> => {
if (!selectedFile) return;
setImporting(true);
try {
const r = await run(selectedFile);
setResult(r);
setPreviewRows([]);
notify.success(t("imported"));
} catch (e) {
notify.error(String(e));
} finally {
setImporting(false);
}
};
const steps = [t("step1"), t("step2"), t("step3"), t("step4")];
const showPreview =
selectedFile && previewRows.length > 0 && !result && !importing;
const showImporting = importing || (loading && !!selectedFile && !result);
return (
<div className="flex flex-col gap-6">
@@ -187,25 +245,81 @@ export function UsersImportClient(): React.ReactElement {
{t("uploadButton")}
<input
type="file"
accept=".csv"
disabled={loading}
accept=".csv,.xlsx,.xls"
disabled={showImporting}
onChange={handleFileChange}
className="hidden"
/>
</label>
<p className="text-xs text-muted-foreground">{t("uploadHint")}</p>
{selectedFile ? (
<p className="text-xs text-muted-foreground">
{selectedFile.name}{selectedFile.size} bytes
</p>
<div className="flex flex-wrap items-center gap-3">
<p className="text-xs text-muted-foreground">
{selectedFile.name}{selectedFile.size} bytes
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleReselect}
disabled={showImporting}
>
<RotateCcw className="size-3" />
{t("reselect")}
</Button>
</div>
) : null}
{loading ? (
<p className="text-xs text-muted-foreground">...</p>
{showImporting ? (
<p className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t("importStatus")}
</p>
) : null}
</div>
</CardContent>
</Card>
{/* 文件预览(前 50 行) */}
{showPreview ? (
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("filesPreview", { count: previewRows.length })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<tbody className="divide-y">
{previewRows.map((row, idx) => (
<tr
key={idx}
className={idx === 0 ? "bg-muted/30 font-medium" : ""}
>
{row.map((cell, j) => (
<td key={j} className="p-2 font-mono text-xs">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 flex justify-end">
<Button
type="button"
onClick={handleConfirmImport}
disabled={showImporting}
>
<Upload className="size-4" />
{t("confirmImport")}
</Button>
</div>
</CardContent>
</Card>
) : null}
{/* 步骤 4查看结果 */}
{result ? (
<Card>

View File

@@ -5,7 +5,7 @@
*
* 数据契约:
* - 列表查询 users(role, limit, offset) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 变更 updateUserStatus / updateUserRole ❌ schema 无 → MSW 兜底(@contract-pending
* - 变更 updateUserStatus / updateUserRole / deleteUser / assignUserRoles ❌ schema 无 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/iam_contract.md#users
*
* URL 状态:?search=&role=&page=
@@ -13,25 +13,49 @@
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState + 行动按钮)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
* 对齐 CICDsrc/modules/users/components/admin-users-view.tsx
*/
import { Users } from "lucide-react";
import {
Users,
Upload,
MoreHorizontal,
Trash2,
Pencil,
UserCog,
RotateCcw,
} from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import {
useDeleteUser,
useRoles,
useUpdateUserRole,
useUpdateUserStatus,
useUsers,
type User,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog";
import {
UserRoleAssignDialog,
type AssignableRole,
} from "@/features/admin/users/user-role-assign-dialog";
import {
USER_ROLE_OPTIONS,
formatUserDate,
@@ -71,15 +95,35 @@ export function UsersListClient(): React.ReactElement {
offset,
});
// 拉取角色列表供多角色分配对话框使用
const { data: rolesData } = useRoles();
const updateUserStatus = useUpdateUserStatus();
const updateUserRole = useUpdateUserRole();
const deleteUser = useDeleteUser();
const [pendingId, setPendingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [assignTarget, setAssignTarget] = useState<User | null>(null);
const filteredItems = useMemo<User[]>(() => {
const items = data?.items ?? [];
return items.filter((u) => matchUserSearch(u, search));
}, [data, search]);
// 将 roles 数据映射为 AssignableRole兼容旧 Role 结构)
const assignableRoles: AssignableRole[] = useMemo(() => {
if (!rolesData) {
return [];
}
return rolesData.map((r) => ({
id: r.id,
name: r.name,
description: r.description ?? "",
isSystem: r.isLocked ?? false,
isEnabled: true,
}));
}, [rolesData]);
const total = data?.total ?? filteredItems.length;
const hasNext = page * PAGE_SIZE < total;
const hasPrev = page > 1;
@@ -111,6 +155,12 @@ export function UsersListClient(): React.ReactElement {
});
};
const handleReset = (): void => {
startTransition(() => {
router.push("/shell/admin/users");
});
};
const handleRoleChange = async (
user: User,
newRole: string,
@@ -140,6 +190,21 @@ export function UsersListClient(): React.ReactElement {
}
};
const handleDelete = async (): Promise<void> => {
if (!deleteTarget) return;
setPendingId(deleteTarget.id);
try {
await deleteUser.run(deleteTarget.id);
notify.success(t("list.deleted"));
setDeleteTarget(null);
router.refresh();
} catch (e) {
notify.error(String(e));
} finally {
setPendingId(null);
}
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
@@ -168,6 +233,14 @@ export function UsersListClient(): React.ReactElement {
title={t("list.title")}
description={t("list.description")}
icon={<Users className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/admin/users/import">
<Upload className="mr-2 size-4" />
{t("list.importButton")}
</Link>
</Button>
}
filters={
<>
<FilterSearchInput
@@ -188,6 +261,17 @@ export function UsersListClient(): React.ReactElement {
</option>
))}
</select>
{(search || role) && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleReset}
>
<RotateCcw className="mr-1 size-3" />
{t("list.reset")}
</Button>
)}
</>
}
loading={loading}
@@ -197,7 +281,7 @@ export function UsersListClient(): React.ReactElement {
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-3 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
<span>{t("list.total", { count: total })}</span>
<div className="flex gap-1">
<button
type="button"
@@ -225,26 +309,63 @@ export function UsersListClient(): React.ReactElement {
pendingId={pendingId}
onRoleChange={handleRoleChange}
onToggleStatus={handleToggleStatus}
onAssignRoles={(u) => setAssignTarget(u)}
onDelete={(u) => setDeleteTarget(u)}
/>
<ConfirmDeleteDialog
open={!!deleteTarget}
onOpenChange={(v) => !v && setDeleteTarget(null)}
title={t("list.deleteConfirmTitle")}
description={t("list.deleteConfirmDescription", {
name: deleteTarget?.name ?? "",
email: deleteTarget?.email ?? "",
})}
confirmText={t("list.confirmDelete")}
cancelText={tCommon("button.cancel")}
onConfirm={handleDelete}
isWorking={pendingId === deleteTarget?.id}
/>
{assignTarget && assignableRoles.length > 0 ? (
<UserRoleAssignDialog
open={!!assignTarget}
onOpenChange={(v) => !v && setAssignTarget(null)}
userId={assignTarget.id}
userName={assignTarget.name}
userEmail={assignTarget.email}
allRoles={assignableRoles}
currentRoleNames={
assignTarget.roles && assignTarget.roles.length > 0
? assignTarget.roles
: [assignTarget.role]
}
/>
) : null}
</ListPageShell>
);
}
/**
* 用户列表表格(纯展示组件,对齐 §8.2 排版规范)。
* 用户列表表格(含下拉菜单操作)。
*/
function UsersTable({
items,
pendingId,
onRoleChange,
onToggleStatus,
onAssignRoles,
onDelete,
}: {
items: User[];
pendingId: string | null;
onRoleChange: (user: User, newRole: string) => void;
onToggleStatus: (user: User) => void;
onAssignRoles: (user: User) => void;
onDelete: (user: User) => void;
}): React.ReactElement {
const t = useTranslations("admin.users");
const router = useRouter();
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
@@ -253,60 +374,112 @@ function UsersTable({
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
<th className="p-3 text-left font-medium">{t("list.colEmail")}</th>
<th className="p-3 text-left font-medium">{t("list.colRole")}</th>
<th className="p-3 text-left font-medium">{t("list.colPhone")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colCreatedAt")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colUpdatedAt")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((u) => (
<tr key={u.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{truncateUserName(u.name)}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{u.email}
</td>
<td className="p-3">
<RoleBadge role={u.role} />
</td>
<td className="p-3">
<StatusBadge status={u.status} />
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatUserDate(u.createdAt)}
</td>
<td className="p-3">
<div className="flex items-center gap-2">
<select
value={u.role}
aria-label={t("list.editRole")}
disabled={pendingId === u.id}
onChange={(e) => onRoleChange(u, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs disabled:opacity-50"
>
{USER_ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
{items.map((u) => {
const roleList = u.roles && u.roles.length > 0 ? u.roles : [u.role];
return (
<tr key={u.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">
<div className="flex items-center gap-2">
<span>{truncateUserName(u.name)}</span>
{u.userType ? (
<UserTypeBadge userType={u.userType} />
) : null}
</div>
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{u.email}
</td>
<td className="p-3">
<div className="flex flex-wrap gap-1">
{roleList.map((r) => (
<RoleBadge key={r} role={r} />
))}
</select>
<button
type="button"
disabled={pendingId === u.id}
onClick={() => onToggleStatus(u)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs transition-colors hover:bg-accent disabled:opacity-50"
>
{isUserActive(u)
? t("list.deactivate")
: t("list.activate")}
</button>
</div>
</td>
</tr>
))}
</div>
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{u.phone ?? "-"}
</td>
<td className="p-3">
<StatusBadge status={u.status} />
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatUserDate(u.createdAt)}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatUserDate(u.updatedAt)}
</td>
<td className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={pendingId === u.id}
aria-label={t("list.actionsMenu")}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(`/shell/admin/users/${u.id}`)
}
>
<Pencil className="mr-2 size-4" />
{t("list.editUser")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onToggleStatus(u)}
disabled={pendingId === u.id}
>
<Pencil className="mr-2 size-4" />
{isUserActive(u)
? t("list.deactivate")
: t("list.activate")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onAssignRoles(u)}
disabled={pendingId === u.id}
>
<UserCog className="mr-2 size-4" />
{t("list.assignRoles")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onRoleChange(u, "teacher")}
disabled={pendingId === u.id}
>
<UserCog className="mr-2 size-4" />
{t("list.quickSetTeacher")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(u)}
disabled={pendingId === u.id}
className="text-destructive"
>
<Trash2 className="mr-2 size-4" />
{t("list.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -342,3 +515,25 @@ function StatusBadge({ status }: { status: string }): React.ReactElement {
</span>
);
}
/**
* 用户类型徽章internal/external
*/
function UserTypeBadge({ userType }: { userType: string }): React.ReactElement {
const t = useTranslations("admin.users");
const isInternal = userType === "internal";
const label = isInternal
? t("list.userTypeInternal")
: t("list.userTypeExternal");
const cls = isInternal
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground";
return (
<span
className={`inline-flex h-5 items-center rounded-full px-2 text-xs font-medium ${cls}`}
aria-label={label}
>
{label}
</span>
);
}

View File

@@ -245,6 +245,7 @@ function ViewportEditDialog({
onSave: (input: ViewportInput) => Promise<void>;
}): React.ReactElement {
const t = useTranslations("admin.viewports.list");
const tCommon = useTranslations("common");
const [name, setName] = useState("");
const [route, setRoute] = useState("");
@@ -332,7 +333,7 @@ function ViewportEditDialog({
</FormField>
<div className="flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onClick={onClose}>
{tCommon0("button.cancel")}
{tCommon("button.cancel")}
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{t("saveButton")}
@@ -344,17 +345,6 @@ function ViewportEditDialog({
);
}
/**
* 模拟 useTranslations("common") 在编辑对话框内的访问。
* 注:因该组件嵌套较深,使用辅助函数避免在子组件内重复声明。
*/
function tCommon0(key: string): string {
// 简化处理:直接返回 key 的最后一段作为兜底文案。
// 真实场景应通过 useTranslations("common") 获取。
void key;
return "取消";
}
/**
* 表单字段容器label + children
*/

File diff suppressed because it is too large Load Diff

View File

@@ -105,6 +105,12 @@ export interface AuditLog {
ip: string;
timestamp: string;
details: string;
/**
* 审计日志状态success/failure/error/pending
* @contract-pending 当前 schema 未提供此字段MSW 兜底时为 undefined
* 列表 StatusBadge 显示 "--" 占位;契约补齐后切换为真实值。
*/
status?: string | null;
}
export interface AuditLogFilter {

View File

@@ -476,6 +476,17 @@ export const DELETE_SCHOOL_DOC = gql`
}
`;
// P5 多校 CRUD按 ID 更新学校(区别于 school-settings 的单校 UpdateSchool($input!)
// operationName 用 AdminUpdateSchool 避免与 school-settings 的 UpdateSchool 重名导致 codegen 重复声明
export const ADMIN_UPDATE_SCHOOL_DOC = gql`
mutation AdminUpdateSchool($id: ID!, $input: SchoolInput!) {
updateSchool(id: $id, input: $input) {
id
name
}
}
`;
// ── School: departments ──
export const GET_DEPARTMENTS_DOC = gql`
query GetDepartments {
@@ -585,7 +596,7 @@ export const GET_GRADE_OVERVIEW_STATS_DOC = gql`
`;
export const ADMIN_CREATE_GRADE_DOC = gql`
mutation CreateGrade($input: GradeInput!) {
mutation AdminCreateGrade($input: GradeInput!) {
createGrade(input: $input) {
id
name
@@ -1186,7 +1197,7 @@ export const GET_ADMIN_SCHEDULE_ENTRIES_DOC = gql`
`;
export const ADMIN_GET_SCHEDULING_RULES_DOC = gql`
query GetSchedulingRules {
query AdminGetSchedulingRules {
schedulingRules {
id
name

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff