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:
@@ -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 {
|
||||
|
||||
13
apps/portal-shell/src/app/shell/admin/classes/page.tsx
Normal file
13
apps/portal-shell/src/app/shell/admin/classes/page.tsx
Normal 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");
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
* 业务逻辑在 PluginsClient(client 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
|
||||
*/
|
||||
|
||||
12
apps/portal-shell/src/app/shell/admin/scheduling/page.tsx
Normal file
12
apps/portal-shell/src/app/shell/admin/scheduling/page.tsx
Normal 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");
|
||||
}
|
||||
@@ -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 要求)。
|
||||
* 业务逻辑在 GradeInsightsClient(client 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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-pending:MSW 兜底
|
||||
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">
|
||||
<Button type="button" onClick={handleOpenCreate}>
|
||||
<Plus className="size-4" />
|
||||
{t("addProviderButton")}
|
||||
</a>
|
||||
</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,6 +394,7 @@ function UsageDashboardSection({
|
||||
{byProvider.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("emptyUsage")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
@@ -318,7 +414,7 @@ function UsageDashboardSection({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{byProvider.map((row) => (
|
||||
{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">
|
||||
@@ -335,6 +431,51 @@ function UsageDashboardSection({
|
||||
</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>
|
||||
</Card>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,22 +5,38 @@
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
/** AI Provider 类型标签映射 */
|
||||
/** AI Provider 类型标签映射(对齐 CICD:zhipu/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-pending,MSW 兜底) */
|
||||
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 徽章语义类名。
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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-pending:MSW 兜底
|
||||
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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,12 +176,15 @@ export function AnnouncementsListClient(): React.ReactElement {
|
||||
description={t("description")}
|
||||
icon={<Megaphone className="size-6" />}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href="/shell/admin/announcements/new">
|
||||
<AnnouncementCreateDialog
|
||||
trigger={
|
||||
<Button>
|
||||
<Plus className="size-4" />
|
||||
{t("newAnnouncement")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
filters={
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t("filterStatus")}</span>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 年级多选下拉组件(CICD announcements AnnouncementForm 多选下拉对齐)
|
||||
*
|
||||
* 数据契约:useGrades() 来自 admin-p5.ts(@contract-pending,MSW 兜底)
|
||||
*
|
||||
* 关联: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>
|
||||
);
|
||||
}
|
||||
@@ -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: "王老师",
|
||||
|
||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
@@ -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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考勤记录列表(ARCHITECTURE.md §5.4 / §9.4 / §10 P5)
|
||||
*
|
||||
* 基于 CICD 项目 src/modules/attendance/components/attendance-record-list.tsx
|
||||
* 适配到 portal-shell:Server Actions → Apollo Client + MSW 兜底。
|
||||
*
|
||||
* 数据契约:
|
||||
* - adminAttendanceRecords(filter, pagination):❌ schema 无 → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* URL 分页:?page=N,page 从 1 开始;切换筛选时由父组件重置 page。
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,11 +9,21 @@
|
||||
* - dataChangeActionStats():❌ schema 无 → MSW 兜底
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ 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,72 +85,176 @@ 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")}>
|
||||
{/* 统计卡片 */}
|
||||
{/* 统计卡片: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("statsTotal")}
|
||||
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={BarChart3}
|
||||
icon={FileText}
|
||||
href="/shell/admin/audit-logs"
|
||||
description={t("sectionStats")}
|
||||
valueClassName="text-purple-600 dark:text-purple-400"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("statsToday")}
|
||||
value={stats?.totalToday ?? 0}
|
||||
icon={BarChart3}
|
||||
</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("statsErrors")}
|
||||
value={stats?.totalErrors ?? 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("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")}
|
||||
/>
|
||||
</ChartCardShell>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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")}
|
||||
/>
|
||||
</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>
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷入口卡片(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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,24 +8,25 @@
|
||||
* - dataChangeTableOptions():❌ schema 无 → MSW 兜底
|
||||
* - dataChangeStats():❌ schema 无 → MSW 兜底
|
||||
*
|
||||
* URL 状态:?page=&table=&action=&userId=
|
||||
* URL 状态:?page=&table=&action=&userId=&startDate=&endDate=
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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-pending:MSW 兜底
|
||||
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}
|
||||
|
||||
<SectionErrorBoundary title={t("title")}>
|
||||
<DataChangesTable items={items} />
|
||||
</SectionErrorBoundary>
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据变更日志表格(纯展示组件)。
|
||||
* 支持行内展开 diff(oldValue/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,11 +314,65 @@ 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">
|
||||
{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>
|
||||
@@ -284,13 +387,67 @@ function DataChangesTable({
|
||||
{log.userId}
|
||||
</td>
|
||||
<td className="p-3">{log.userName}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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-pending:MSW 兜底
|
||||
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>
|
||||
}
|
||||
>
|
||||
<SectionErrorBoundary title={t("title")}>
|
||||
<LoginLogsTable items={items} />
|
||||
</SectionErrorBoundary>
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录日志表格(纯展示组件)。
|
||||
* userAgent 截断显示(max-w + truncate),errorMessage 行内展开显示在状态徽章下方。
|
||||
*/
|
||||
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>
|
||||
|
||||
@@ -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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,18 +12,27 @@
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 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-pending:MSW 兜底
|
||||
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) ? (
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态徽章(按状态色阶展示)。
|
||||
*/
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
* - mutation updateCoursePlan(input) ❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
|
||||
* - 选项数据 grades / adminClasses / teacherOptions / academicYears:MSW 兜底
|
||||
* - 选项数据 subjectOptions:❌ 暂无 hook → 文本输入兜底(@contract-pending)
|
||||
* - 周次教学进度:❌ schema 无 → 本地 state + 默认骨架(@contract-pending,保存时暂不同步)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:FormPageSkeleton(加载预填数据)
|
||||
* - error:errorSummary 表单级错误
|
||||
* - success:notify.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:周次教学进度本地 state(schema 无 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-pending:UpdateCoursePlanInput 仅支持 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 课程计划状态徽章(按状态色阶展示)。
|
||||
*/
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 → high(emerald)
|
||||
* - >= 0.5 → medium(blue)
|
||||
* - >= 0.2 → low(amber)
|
||||
* - > 0 → critical(red)
|
||||
* - === 0 → none(muted)
|
||||
* 输入无效(NaN/负数/大于 1)→ none
|
||||
*/
|
||||
export function coverageRateToIntensity(rate: number): CoverageIntensity {
|
||||
@@ -29,14 +32,17 @@ 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
|
||||
* 根据强度返回 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(
|
||||
@@ -44,11 +50,13 @@ export function coverageIntensityToCellClass(
|
||||
): 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,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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-pending:FIRST_COME | LOTTERY) */
|
||||
const SELECTION_MODE_OPTIONS = ["FIRST_COME", "LOTTERY"] as const;
|
||||
|
||||
/**
|
||||
* 新建表单客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
@@ -40,6 +50,7 @@ export function ElectiveCreateClient(): React.ReactElement {
|
||||
// @contract-pending:MSW 兜底
|
||||
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-pending:createElective 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>
|
||||
|
||||
<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
|
||||
<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>
|
||||
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>
|
||||
|
||||
@@ -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-pending:MSW 兜底
|
||||
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) ? (
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -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-pending:FIRST_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-pending:updateElective 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>
|
||||
|
||||
<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
|
||||
<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>
|
||||
onValueChange={setStatus}
|
||||
options={STATUS_OPTIONS.map((s) => ({
|
||||
value: s,
|
||||
label: formatElectiveStatus(s),
|
||||
}))}
|
||||
className="h-9 w-full"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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-pending:MSW 兜底
|
||||
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,27 +398,28 @@ 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,
|
||||
@@ -233,28 +430,73 @@ function ElectiveTable({
|
||||
enrolledCount: enrolled,
|
||||
})}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{rate}%
|
||||
</span>
|
||||
</div>
|
||||
</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);
|
||||
|
||||
@@ -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-pending:FIRST_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 徽章语义类名。
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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-pending:MSW 兜底
|
||||
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,7 +320,8 @@ function ErrorBookContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 学科分布 */}
|
||||
{/* 学科分布 + 班级分布(并排展示,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">
|
||||
@@ -271,6 +368,53 @@ function ErrorBookContent({
|
||||
</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">
|
||||
<ChapterWeaknessCard stats={stats} />
|
||||
@@ -328,13 +472,18 @@ function ErrorBookContent({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top 10 高频错题 */}
|
||||
{/* 错题列表(分页) */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("topWrongQuestionsTitle")}
|
||||
</h2>
|
||||
{wrongQuestions.length === 0 ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 #8:catch 块必须包含 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");
|
||||
}
|
||||
@@ -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 视为 1,total ≤ 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 开始)。
|
||||
*/
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 文件预览对话框(ARCHITECTURE.md §5.4 / §9.4 / §10 P5)
|
||||
*
|
||||
* 基于 CICD 项目 src/modules/files/components/file-preview-dialog.tsx 适配
|
||||
* 到 portal-shell:Server Actions → Apollo Client + MSW 兜底。
|
||||
*
|
||||
* 组件层次:
|
||||
* - FilePreviewDialog:Dialog 外壳,触发按钮 + 标题 + 内容容器
|
||||
* - FilePreview:根据 mimeType 渲染不同预览体(image/pdf/text/office/other)
|
||||
* - FileIcon:根据 mimeType 渲染分类图标
|
||||
*
|
||||
* 内部 hooks:
|
||||
* - useFilePreview:fetch 文本内容(懒加载,错误重试)
|
||||
* - 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FilePreviewDialog:Dialog 外壳
|
||||
// ============================================================
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
||||
*
|
||||
* 关联: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,150 +13,36 @@ 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)。
|
||||
*/
|
||||
export function FilesListClient(): React.ReactElement {
|
||||
const t = useTranslations("admin.files.list");
|
||||
const tCommon = useTranslations("common");
|
||||
const [search, setSearch] = useState("");
|
||||
/** 文件类型筛选选项(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" },
|
||||
];
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useFileAttachments({
|
||||
limit: DEFAULT_LIMIT,
|
||||
});
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data: stats, loading: statsLoading } = useFileStats();
|
||||
|
||||
const allItems = 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]);
|
||||
|
||||
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 emptyNode = (
|
||||
<EmptyState
|
||||
icon={Files}
|
||||
title={t("emptyTitle")}
|
||||
description={t("emptyDescription")}
|
||||
action={{
|
||||
label: t("emptyAction"),
|
||||
href: "/shell/admin/files",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
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}
|
||||
/>
|
||||
}
|
||||
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} />
|
||||
</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<{
|
||||
interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
@@ -177,56 +50,325 @@ function FilesTable({
|
||||
url: string;
|
||||
uploadedBy: string;
|
||||
uploadedAt: string;
|
||||
}>;
|
||||
}): React.ReactElement {
|
||||
}
|
||||
|
||||
export function FilesListClient(): React.ReactElement {
|
||||
const t = useTranslations("admin.files.list");
|
||||
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());
|
||||
|
||||
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,
|
||||
});
|
||||
const { data: stats, loading: statsLoading } = useFileStats();
|
||||
|
||||
const allItems: FileItem[] = data?.items ?? [];
|
||||
const filteredItems = useMemo(() => {
|
||||
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 allSelected =
|
||||
filteredItems.length > 0 &&
|
||||
filteredItems.every((item) => selectedIds.has(item.id));
|
||||
const someSelected = selectedIds.size > 0 && !allSelected;
|
||||
|
||||
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 (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<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={actions}
|
||||
filters={filters}
|
||||
empty={filteredItems.length === 0}
|
||||
emptyNode={
|
||||
<EmptyState
|
||||
title={t("emptyTitle")}
|
||||
description={t("emptyDescription")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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="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>
|
||||
<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 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)}
|
||||
<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="p-3 text-muted-foreground">
|
||||
{formatMimeType(item.mimeType)}
|
||||
<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="p-3 text-muted-foreground">{item.uploadedBy}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatFileDate(item.uploadedAt)}
|
||||
<td className="px-3 py-3 text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</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">
|
||||
<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>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => notify.warning(t("mswNotice"))}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("total", { count: filteredItems.length })}
|
||||
</div>
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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-pending:MSW 兜底
|
||||
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)}
|
||||
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)}
|
||||
<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}
|
||||
/>
|
||||
) : null}
|
||||
<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={items}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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-pending:MSW 兜底
|
||||
const { data, loading, error } = useAdminLessonPlan(planId);
|
||||
|
||||
// 软删除 mutation(@contract-pending,MSW 兜底)
|
||||
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,11 +101,33 @@ 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"
|
||||
actions={actions}
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
@@ -80,6 +135,13 @@ export function AdminLessonPlanViewClient(): React.ReactElement {
|
||||
>
|
||||
{data ? <AdminLessonPlanViewBody plan={data} /> : null}
|
||||
</DetailPageShell>
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
loading={deleteLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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-pending:MSW 兜底
|
||||
const { data, loading, error } = useAdminLessonPlans();
|
||||
|
||||
// 软删除 mutation(@contract-pending,MSW 兜底)
|
||||
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,6 +163,7 @@ export function AdminLessonPlansListClient(): React.ReactElement {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListPageShell
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
@@ -145,13 +200,84 @@ export function AdminLessonPlansListClient(): 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: filteredItems.length })}</span>
|
||||
</div>
|
||||
<Pagination
|
||||
page={safePage}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
onJump={(p) => updateQuery("page", String(p))}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdminLessonPlansTable items={filteredItems} />
|
||||
<div className="flex flex-col gap-6">
|
||||
<LessonPlansStatsCards items={filteredItems} loading={loading} />
|
||||
<AdminLessonPlansTable
|
||||
items={pagedItems}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -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 徽章语义类名。
|
||||
*/
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 #8:catch 块必须包含 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>
|
||||
);
|
||||
}
|
||||
@@ -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-pending:MSW 兜底
|
||||
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,15 +131,69 @@ 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 (
|
||||
<>
|
||||
<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={
|
||||
<Button asChild>
|
||||
<Link href="/shell/admin/questions">{t("createButton")}</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleCreateClick}>{t("createButton")}</Button>
|
||||
<ImportExportButtons
|
||||
filter={exportFilter}
|
||||
onImported={handleImported}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
@@ -160,32 +242,83 @@ export function AdminQuestionsListClient(): 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 ?? filteredItems.length })}
|
||||
</span>
|
||||
</div>
|
||||
<Pagination
|
||||
page={safePage}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
onJump={(p) => updateQuery("page", String(p))}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdminQuestionsTable items={filteredItems} />
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</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,8 +330,19 @@ function AdminQuestionsTable({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((q) => (
|
||||
<tr key={q.id} className="hover:bg-muted/30">
|
||||
{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)}
|
||||
@@ -227,15 +371,18 @@ function AdminQuestionsTable({
|
||||
{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"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onViewDetail(q.id)}
|
||||
>
|
||||
{t("viewDetail")}
|
||||
</Link>
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
213
apps/portal-shell/src/features/admin/roles/role-form-dialog.tsx
Normal file
213
apps/portal-shell/src/features/admin/roles/role-form-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,17 +176,29 @@ 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" />}
|
||||
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")}
|
||||
@@ -110,31 +212,75 @@ export function RolesListClient(): React.ReactElement {
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
>
|
||||
<RolesTable items={filteredItems} />
|
||||
<RolesTable
|
||||
items={filteredItems}
|
||||
onEdit={handleEdit}
|
||||
onDelete={(r) => setDeleteTarget(r)}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
pendingId={pendingId}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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,7 +231,12 @@ export function AcademicYearClient(): React.ReactElement {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AcademicYearTable
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<ActiveYearSidebarCard
|
||||
items={filteredItems}
|
||||
schoolNameMap={schoolNameMap}
|
||||
/>
|
||||
<AcademicYearTableCard
|
||||
items={filteredItems}
|
||||
schoolNameMap={schoolNameMap}
|
||||
onEdit={(y) => {
|
||||
@@ -216,6 +245,7 @@ export function AcademicYearClient(): React.ReactElement {
|
||||
}}
|
||||
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,32 +394,49 @@ 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">
|
||||
<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) => (
|
||||
<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">
|
||||
<TableRow key={y.id}>
|
||||
<TableCell className="font-medium">
|
||||
{truncateText(y.name)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{schoolNameMap.get(y.schoolId) ?? y.schoolId}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatSchoolDay(y.startDate)}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatSchoolDay(y.endDate)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${activeToBadgeClass(
|
||||
y.isActive,
|
||||
@@ -292,8 +444,8 @@ function AcademicYearTable({
|
||||
>
|
||||
{y.isActive ? t("active") : t("inactive")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -312,12 +464,15 @@ function AcademicYearTable({
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
|
||||
</h2>
|
||||
</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"
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="size-4 cursor-pointer"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级邀请码管理 - 客户端组件(ARCHITECTURE.md §7.3 / §9.4 / §10 P5)
|
||||
*
|
||||
* 数据契约:
|
||||
* - useClassInvitationCodes / useGenerateClassInvitationCode / useRevokeClassInvitationCode
|
||||
* ❌ schema 无对应根字段 → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 功能:
|
||||
* - 列出班级所有邀请码(含状态/有效期/使用次数)
|
||||
* - 生成自定义邀请码(可选有效期/次数/备注)
|
||||
* - 撤销邀请码(软删除)
|
||||
* - 复制邀请码到剪贴板
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Ban, Copy, Plus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
useGenerateClassInvitationCode,
|
||||
useRevokeClassInvitationCode,
|
||||
useClassInvitationCodes,
|
||||
type ClassInvitationCode,
|
||||
} from "@/lib/api";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { FormField } from "@/features/admin/school/schools-client";
|
||||
|
||||
// ── ClassInvitationManagerDialog ────────────────────────────────
|
||||
|
||||
/**
|
||||
* 班级邀请码管理对话框。
|
||||
*
|
||||
* - 列表查询:useClassInvitationCodes(classId)
|
||||
* - 生成:useGenerateClassInvitationCode,自定义有效期/次数/备注
|
||||
* - 撤销:useRevokeClassInvitationCode,软删除
|
||||
* - 复制:navigator.clipboard.writeText
|
||||
*
|
||||
* 子对话框:GenerateCodeDialog(生成新邀请码)
|
||||
*/
|
||||
export function ClassInvitationManagerDialog({
|
||||
open,
|
||||
classId,
|
||||
className,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
classId: string;
|
||||
className: string;
|
||||
onClose: () => void;
|
||||
}): React.ReactElement | null {
|
||||
const t = useTranslations("admin.school.classes.invitation");
|
||||
const tCommon = useTranslations("common");
|
||||
const {
|
||||
data: codes,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
} = useClassInvitationCodes(classId);
|
||||
const revokeMutation = useRevokeClassInvitationCode();
|
||||
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const [revokeTarget, setRevokeTarget] = useState<ClassInvitationCode | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleCopy = async (code: string): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
notify.success(t("copied"));
|
||||
} catch (err) {
|
||||
notify.error(tCommon("error.loadFailed", { message: String(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (): Promise<void> => {
|
||||
if (!revokeTarget) return;
|
||||
try {
|
||||
await revokeMutation.run(revokeTarget.id);
|
||||
notify.success(t("revokeSuccess"));
|
||||
setRevokeTarget(null);
|
||||
void refetch();
|
||||
} catch (err) {
|
||||
notify.error(tCommon("error.loadFailed", { message: String(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerated = (): void => {
|
||||
void refetch();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[90vh] w-full max-w-3xl flex-col rounded-xl border bg-background p-6 shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
{className ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("classLabel")}: {className}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setGenerateOpen(true)}>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
{t("generate")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{tCommon("loading")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : codes && codes.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("colCode")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("colStatus")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("colUsedCount")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("colExpiresAt")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("colNote")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{codes.map((record) => (
|
||||
<tr key={record.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-mono font-medium tracking-wider">
|
||||
{record.code}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<InvitationStatusBadge status={record.status} />
|
||||
</td>
|
||||
<td className="p-3 text-sm">
|
||||
{record.usedCount}
|
||||
{record.maxUses !== null ? ` / ${record.maxUses}` : ""}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{record.expiresAt
|
||||
? new Date(record.expiresAt).toLocaleString()
|
||||
: t("neverExpires")}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate p-3 text-sm text-muted-foreground">
|
||||
{record.note ?? "-"}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleCopy(record.code)}
|
||||
aria-label={t("copy")}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
{record.status === "active" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRevokeTarget(record)}
|
||||
aria-label={t("revoke")}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t("empty")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{tCommon("form.close")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<GenerateCodeDialog
|
||||
open={generateOpen}
|
||||
classId={classId}
|
||||
onClose={() => setGenerateOpen(false)}
|
||||
onCreated={handleGenerated}
|
||||
/>
|
||||
|
||||
{revokeTarget ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={() => setRevokeTarget(null)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-xl border bg-background p-6 shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="mb-2 text-lg font-semibold">{t("revoke")}</h2>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{t("revokeConfirm")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setRevokeTarget(null)}
|
||||
disabled={revokeMutation.loading}
|
||||
>
|
||||
{tCommon("form.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={revokeMutation.loading}
|
||||
onClick={() => void handleRevoke()}
|
||||
>
|
||||
{revokeMutation.loading
|
||||
? tCommon("form.processing")
|
||||
: t("revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── InvitationStatusBadge ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 邀请码状态徽章。
|
||||
*
|
||||
* - active:默认变体(primary 色)
|
||||
* - 其他(used/expired/revoked):secondary 变体
|
||||
*/
|
||||
function InvitationStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("admin.school.classes.invitation");
|
||||
const variant = status === "active" ? "default" : "secondary";
|
||||
const label =
|
||||
status === "active" ||
|
||||
status === "used" ||
|
||||
status === "expired" ||
|
||||
status === "revoked"
|
||||
? t(`status.${status}`)
|
||||
: status;
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
}
|
||||
|
||||
// ── GenerateCodeDialog ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 生成新邀请码对话框。
|
||||
*
|
||||
* 字段:有效期(小时)/ 最大使用次数 / 备注
|
||||
* 全部可选,留空则生成无限制邀请码。
|
||||
*/
|
||||
function GenerateCodeDialog({
|
||||
open,
|
||||
classId,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
classId: string;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}): React.ReactElement | null {
|
||||
const t = useTranslations("admin.school.classes.invitation");
|
||||
const tCommon = useTranslations("common");
|
||||
const generateMutation = useGenerateClassInvitationCode();
|
||||
|
||||
const [expiresInHours, setExpiresInHours] = useState<string>("");
|
||||
const [maxUses, setMaxUses] = useState<string>("");
|
||||
const [note, setNote] = useState<string>("");
|
||||
|
||||
const handleSubmit = async (
|
||||
e: React.FormEvent<HTMLFormElement>,
|
||||
): Promise<void> => {
|
||||
e.preventDefault();
|
||||
if (!classId) return;
|
||||
|
||||
try {
|
||||
await generateMutation.run({
|
||||
classId,
|
||||
expiresInHours: expiresInHours ? Number(expiresInHours) : null,
|
||||
maxUses: maxUses ? Number(maxUses) : null,
|
||||
note: note.trim() || null,
|
||||
});
|
||||
notify.success(t("generateSuccess"));
|
||||
setExpiresInHours("");
|
||||
setMaxUses("");
|
||||
setNote("");
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notify.error(tCommon("error.loadFailed", { message: String(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border bg-background p-6 shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="mb-1 text-lg font-semibold">
|
||||
{t("generateWithCustom")}
|
||||
</h2>
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
{t("defaultDuration")} · {t("defaultMaxUses")}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<FormField label={t("expiresInHours")}>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={expiresInHours}
|
||||
onChange={(e) => setExpiresInHours(e.target.value)}
|
||||
placeholder={t("defaultDuration")}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("maxUsesLabel")}>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
placeholder={t("defaultMaxUses")}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("customNote")}>
|
||||
<Input
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("customNotePlaceholder")}
|
||||
maxLength={255}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={generateMutation.loading}
|
||||
>
|
||||
{tCommon("form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={generateMutation.loading}>
|
||||
{generateMutation.loading
|
||||
? tCommon("form.processing")
|
||||
: t("generate")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
* - 新增/编辑:ScheduleFormDialog(mode 切换)
|
||||
* - 删除: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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 页面结构:
|
||||
* 1. 全校汇总统计卡片(4 个 StatCard:平均分 / 及格率 / 优秀率 / 参考人数)
|
||||
* 2. 年级筛选(ChipNav,URL 驱动,无整页刷新)
|
||||
* 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-pending:MSW 兜底,用于 ChipNav 年级选项
|
||||
const { data: gradeOptions } = useGrades();
|
||||
// @contract-pending:MSW 兜底,用于全校汇总 + 班级排名 + 最近作业
|
||||
const { data, loading, error, refetch } =
|
||||
useSchoolWideGradeSummary(selectedGradeId);
|
||||
|
||||
// 错误降级:query 出错时弹出通知(§11.3 DoD:catch 块必须包含 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;
|
||||
}
|
||||
}
|
||||
@@ -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-pending:MSW 兜底
|
||||
const { data: stats, loading: statsLoading } = useGradeOverviewStats();
|
||||
const { data: schools } = useSchools();
|
||||
// @contract-pending:MSW 兜底
|
||||
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,6 +206,13 @@ export function GradesClient(): React.ReactElement {
|
||||
description={t("description")}
|
||||
icon={<GraduationCap className="size-6" />}
|
||||
actions={
|
||||
<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);
|
||||
@@ -187,6 +222,7 @@ export function GradesClient(): React.ReactElement {
|
||||
<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">
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
|
||||
</h2>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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-pending:MSW 兜底
|
||||
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,7 +275,19 @@ 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">
|
||||
<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"
|
||||
@@ -238,6 +296,7 @@ function SchoolsTable({
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
* 对齐 CICD:src/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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<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>
|
||||
|
||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
|
||||
* 对齐 CICD:src/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,24 +374,44 @@ 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) => (
|
||||
{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">{truncateUserName(u.name)}</td>
|
||||
<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">
|
||||
<RoleBadge role={u.role} />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{roleList.map((r) => (
|
||||
<RoleBadge key={r} role={r} />
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{u.phone ?? "-"}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<StatusBadge status={u.status} />
|
||||
@@ -278,35 +419,67 @@ function UsersTable({
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={u.role}
|
||||
aria-label={t("list.editRole")}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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"
|
||||
aria-label={t("list.actionsMenu")}
|
||||
>
|
||||
{USER_ROLE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingId === u.id}
|
||||
<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)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs transition-colors hover:bg-accent disabled:opacity-50"
|
||||
disabled={pendingId === u.id}
|
||||
>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
{isUserActive(u)
|
||||
? t("list.deactivate")
|
||||
: t("list.activate")}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -767,3 +767,12 @@
|
||||
| React 19 use() + Suspense jsdom 测试 | `use(promise)` 在 jsdom 中 promise resolve 后不自动触发重新渲染;需用 `await act(async () => { render(...); await Promise.resolve(); })` 包裹 render,并设置 `globalThis.IS_REACT_ACT_ENVIRONMENT = true`(setup 文件) |
|
||||
| 前端错误上报生产端点 | api-gateway 在 `/api/v1/log` 直接处理(不代理到下游),slog 结构化 JSON 日志,64KB body 限制,返回 204;`useErrorReport` 按 `process.env.NODE_ENV` 切换:production→`/api/v1/log`,development→`/api/log`(Next.js API route mock) |
|
||||
| vitest setup 文件配置 | `vitest.config.ts` 的 `setupFiles: ["src/__tests__/setup.ts"]` 注册 `@testing-library/jest-dom/vitest` matchers + 设置 `IS_REACT_ACT_ENVIRONMENT`;`declare global { var IS_REACT_ACT_ENVIRONMENT: boolean \| undefined }` 补类型签名 |
|
||||
| 管理域 GraphQL operation 命名冲突 | admin.graphql.ts 与 grades.graphql.ts/school-settings.graphql.ts 同名 mutation(CreateGrade/UpdateSchool 等)导致 codegen 重复声明;前缀化命名 `AdminCreateGrade`/`AdminUpdateSchool`/`AdminGetSchedulingRules` 区分 |
|
||||
| 管理域 invitation-codes 纯函数时间漂移 | 纯函数 `isInvitationExpired`/`getEffectiveStatus`/`isInvitationRevocable` 调用 `Date.now()` 会造成 SSR/CSR hydration mismatch;参数化注入 `now: number` 由 server page 传入 |
|
||||
| 管理域 StatusBadge 虚假推断状态 | `audit-logs` StatusBadge 用 `log.details ? "success" : ""` 推断 status 违反数据真实性原则;改用 schema 字段 `log.status`,未就绪时显示 "--" 占位符 |
|
||||
| 管理域未实现功能降级模式 | 未实现的 CRUD(如 questions 创建/导入导出、students/teachers/organization 详情)改为按钮 + `notify.info(t("mswNotice"))` 占位,避免死链;不创建实际路由 |
|
||||
| 管理域 @contract-pending 契约标注规则 | schema 未就绪字段在文件头注释 `@contract-pending: <工单>` + `§11.4 登记`;禁止在代码注释中声称 schema 已就绪而实际走 MSW 兜底(plugins 模块原标注造假已修正) |
|
||||
| 管理域 announcements grades 关联编辑 | AnnouncementInput.grades 字段已有 schema 定义;edit 表单用逗号分隔文本输入(简化版),避免下拉多选依赖 useGrades hook;提交时 `split(",").map(trim).filter(Boolean)` 解析 |
|
||||
| 管理域 audit-logs 分页 | ListPageShell pagination slot 支持 prev/next 按钮 + 当前页/总页数显示;URL 状态 `?page=N`,由 `updateQuery("page", ...)` 控制;总页数 `Math.ceil(total / pageSize)` |
|
||||
| 管理域 ai-settings 双权限注释 | AI_CHAT(普通用户访问 private provider)+ AI_CONFIGURE(管理员访问 public/他人 provider);admin 路由仅放行 ["admin"],管理员隐含 AI_CONFIGURE;普通用户 AI_CHAT 走 `/shell/ai-settings`(非 admin 域) |
|
||||
| 管理域 viewports DataScope 注释 | 视口配置属管理员全局视角(DataScope = "all"),不随班级/年级范围收窄;route-permissions.ts 仅放行 ["admin"];无需 ctx.dataScope 上下文 |
|
||||
|
||||
Reference in New Issue
Block a user