>(new Set());
+
+ useEffect(() => {
+ const timer = setTimeout(() => setSearch(searchInput), 300);
+ return () => clearTimeout(timer);
+ }, [searchInput, setSearch]);
+
+ const toggleSelect = (id: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const toggleSelectAll = () => {
+ if (!data || data.items.length === 0) return;
+ if (selected.size === data.items.length) {
+ setSelected(new Set());
+ } else {
+ setSelected(new Set(data.items.map((f) => f.id)));
+ }
+ };
+
+ const handleBatchDelete = async () => {
+ if (selected.size === 0) {
+ show("warning", "请先选择文件");
+ return;
+ }
+ if (!window.confirm(`确认删除选中的 ${selected.size} 个文件?`)) return;
+ const ok = await deleteFiles(Array.from(selected));
+ if (ok) {
+ show("success", `已删除 ${selected.size} 个文件`);
+ setSelected(new Set());
+ refetch();
+ } else {
+ show("error", "删除失败");
+ }
+ };
+
+ return (
+
+
+ {t("admin.files.batchDelete")} ({selected.size})
+
+ }
+ />
+
+ {/* 统计摘要 */}
+ {stats && (
+
+
+
+ {t("admin.files.totalFiles")}
+
+
+ {stats.totalFiles}
+
+
+
+
+ {t("admin.files.totalSize")}
+
+
+ {formatFileSize(stats.totalSize)}
+
+
+
+ )}
+
+ {/* 筛选栏 */}
+
+
+ setSearchInput(e.target.value)}
+ style={{ width: 240 }}
+ />
+
+
+
+
+
+ {/* 文件列表 */}
+
+ {loading && }
+ {error && }
+ {!loading && !error && data && (
+ <>
+ {data.items.length === 0 ? (
+
+ ) : (
+ <>
+
+ {data.items.map((file) => {
+ const isImage = file.mimeType.startsWith("image/");
+ const isPdf = file.mimeType === "application/pdf";
+ return (
+
+
+ toggleSelect(file.id)}
+ />
+
+
+ {isImage ? (
+
+

+
{file.filename}
+
+ ) : isPdf ? (
+
+ {file.filename}
+
+ ) : (
+ {file.filename}
+ )}
+
+
+
+ {file.mimeType}
+
+
+
+ {formatFileSize(file.size)}
+
+ {file.entityType}
+ {file.uploadedByName}
+
+ {new Date(file.createdAt).toLocaleDateString("zh-CN")}
+
+
+ );
+ })}
+
+
+
+
+
+ >
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/admin-portal/src/app/admin/invitation-codes/page.tsx b/apps/admin-portal/src/app/admin/invitation-codes/page.tsx
new file mode 100644
index 0000000..e733e10
--- /dev/null
+++ b/apps/admin-portal/src/app/admin/invitation-codes/page.tsx
@@ -0,0 +1,538 @@
+"use client";
+
+import { type ReactNode, useState, useEffect, useMemo } from "react";
+import {
+ useInvitationCodes,
+ useInvitationCodeFilter,
+ useGenerateInvitationCodes,
+ useDeleteInvitationCodes,
+} from "@/hooks/use-invitation-codes";
+import { useRoles } from "@/hooks/use-roles";
+import { useClasses } from "@/hooks/use-classes";
+import { useToast } from "@/providers/toast-provider";
+import {
+ PageHeader,
+ PaperCard,
+ Input,
+ Select,
+ Button,
+ Badge,
+ LoadingState,
+ ErrorState,
+ EmptyState,
+ Table,
+ TableRow,
+ TableCell,
+ Pagination,
+} from "@/components/ui";
+import { t } from "@/lib/i18n";
+
+export default function InvitationCodesPage(): ReactNode {
+ const { filter, setPage, setSearch, setStatus } = useInvitationCodeFilter();
+ const { data, loading, error } = useInvitationCodes(filter);
+ const { data: roles } = useRoles();
+ const { data: classesData } = useClasses({ page: 1, pageSize: 100 });
+ const { show } = useToast();
+ const [generateCodes] = useGenerateInvitationCodes();
+ const [deleteCodes] = useDeleteInvitationCodes();
+ const [searchInput, setSearchInput] = useState("");
+ const [selectedIds, setSelectedIds] = useState
>(new Set());
+
+ // 生成对话框
+ const [generateOpen, setGenerateOpen] = useState(false);
+ const [genCount, setGenCount] = useState(10);
+ const [genRoleId, setGenRoleId] = useState("");
+ const [genClassId, setGenClassId] = useState("");
+ const [genEmail, setGenEmail] = useState("");
+ const [generating, setGenerating] = useState(false);
+
+ // 生成结果对话框
+ const [resultOpen, setResultOpen] = useState(false);
+ const [generatedCodes, setGeneratedCodes] = useState([]);
+ const [generatedBatchId, setGeneratedBatchId] = useState("");
+
+ useEffect(() => {
+ const timer = setTimeout(() => setSearch(searchInput), 300);
+ return () => clearTimeout(timer);
+ }, [searchInput, setSearch]);
+
+ const classes = useMemo(() => classesData?.items ?? [], [classesData]);
+
+ const allChecked = useMemo(
+ () =>
+ data !== null &&
+ data.items.length > 0 &&
+ data.items.every((ic) => selectedIds.has(ic.id)),
+ [data, selectedIds],
+ );
+
+ const toggleSelect = (id: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ return next;
+ });
+ };
+
+ const toggleSelectAll = () => {
+ if (!data) return;
+ if (allChecked) {
+ setSelectedIds(new Set());
+ } else {
+ setSelectedIds(new Set(data.items.map((ic) => ic.id)));
+ }
+ };
+
+ const openGenerateDialog = () => {
+ setGenCount(10);
+ setGenRoleId("");
+ setGenClassId("");
+ setGenEmail("");
+ setGenerateOpen(true);
+ };
+
+ const handleGenerate = async () => {
+ if (genCount < 1 || genCount > 1000) {
+ show("warning", "数量范围 1-1000");
+ return;
+ }
+ setGenerating(true);
+ try {
+ const result = await generateCodes({
+ count: genCount,
+ roleId: genRoleId || undefined,
+ classId: genClassId || undefined,
+ email: genEmail || undefined,
+ });
+ if (result) {
+ show(
+ "success",
+ t("admin.invitationCodes.generateSuccess", {
+ count: result.codes.length,
+ }),
+ );
+ setGeneratedCodes(result.codes);
+ setGeneratedBatchId(result.batchId);
+ setGenerateOpen(false);
+ setResultOpen(true);
+ }
+ } catch (err) {
+ show(
+ "error",
+ "生成失败",
+ err instanceof Error ? err.message : String(err),
+ );
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ const handleBatchDelete = async () => {
+ if (selectedIds.size === 0) {
+ show("warning", "请先选择要删除的邀请码");
+ return;
+ }
+ if (!window.confirm(`确认删除选中的 ${selectedIds.size} 个邀请码?`)) {
+ return;
+ }
+ try {
+ const ok = await deleteCodes(Array.from(selectedIds));
+ if (ok) {
+ show("success", "删除成功");
+ setSelectedIds(new Set());
+ window.location.reload();
+ }
+ } catch (err) {
+ show(
+ "error",
+ "删除失败",
+ err instanceof Error ? err.message : String(err),
+ );
+ }
+ };
+
+ const handleCopyCodes = async () => {
+ try {
+ await navigator.clipboard.writeText(generatedCodes.join("\n"));
+ show("success", "已复制到剪贴板");
+ } catch {
+ show("error", "复制失败");
+ }
+ };
+
+ return (
+
+
+ {t("admin.invitationCodes.generate")}
+
+ }
+ />
+
+
+
+ setSearchInput(e.target.value)}
+ style={{ width: 240 }}
+ />
+
+ {selectedIds.size > 0 && (
+
+ )}
+
+
+
+
+ {loading && }
+ {error && }
+ {!loading && !error && data && (
+ <>
+ {data.items.length === 0 ? (
+
+ ) : (
+ <>
+
+
+
+
+ {
+ setSelectedIds(new Set());
+ setPage(p);
+ }}
+ />
+ >
+ )}
+ >
+ )}
+
+
+ {/* 生成对话框 */}
+ {generateOpen && (
+
+
+
+ {t("admin.invitationCodes.generate")}
+
+
+
+
+ {t("admin.invitationCodes.generateCount")}(1-1000)
+
+ setGenCount(Number(e.target.value) || 0)}
+ style={{ width: 120 }}
+ />
+
+
+
+ {t("admin.invitationCodes.bindRole")}
+
+
+
+
+
+ {t("admin.invitationCodes.bindClass")}(可选)
+
+
+
+
+
+ {t("admin.invitationCodes.bindEmail")}(可选)
+
+ setGenEmail(e.target.value)}
+ placeholder="user@edu.test"
+ />
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 生成结果对话框 */}
+ {resultOpen && (
+
+
+
+ {t("admin.invitationCodes.generateSuccess", {
+ count: generatedCodes.length,
+ })}
+
+
+ {t("admin.invitationCodes.batch")}:{generatedBatchId}
+
+
+
+ {generatedCodes.join("\n")}
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/admin-portal/src/app/admin/school/academic-year/page.tsx b/apps/admin-portal/src/app/admin/school/academic-year/page.tsx
new file mode 100644
index 0000000..d1d86e6
--- /dev/null
+++ b/apps/admin-portal/src/app/admin/school/academic-year/page.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { type ReactNode } from "react";
+import { useAcademicYears } from "@/hooks/use-school";
+import {
+ PaperCard,
+ LoadingState,
+ ErrorState,
+ EmptyState,
+ Table,
+ TableRow,
+ TableCell,
+} from "@/components/ui";
+import { t } from "@/lib/i18n";
+import type { AcademicYearViewModel } from "@/types/view-models";
+
+function CurrentBadge(): ReactNode {
+ const color = "var(--color-success)";
+ return (
+
+ {t("admin.school.currentYear")}
+
+ );
+}
+
+export default function AcademicYearPage(): ReactNode {
+ const { data, loading, error } = useAcademicYears();
+
+ return (
+
+ {loading && }
+ {error && }
+ {!loading && !error && (
+ <>
+ {data.length === 0 ? (
+
+ ) : (
+
+ {data.map((year: AcademicYearViewModel) => (
+
+
+ {year.name}
+
+
+ {year.startDate}
+
+
+ {year.endDate}
+
+
+ {year.firstSemesterStart} ~ {year.firstSemesterEnd}
+
+
+ {year.secondSemesterStart} ~ {year.secondSemesterEnd}
+
+
+ {year.isCurrent ? : "—"}
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/admin-portal/src/app/admin/school/departments/page.tsx b/apps/admin-portal/src/app/admin/school/departments/page.tsx
new file mode 100644
index 0000000..10c7603
--- /dev/null
+++ b/apps/admin-portal/src/app/admin/school/departments/page.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { type ReactNode, useState } from "react";
+import { useDepartments, useSchools } from "@/hooks/use-school";
+import {
+ PaperCard,
+ Select,
+ LoadingState,
+ ErrorState,
+ EmptyState,
+ Table,
+ TableRow,
+ TableCell,
+} from "@/components/ui";
+import { t } from "@/lib/i18n";
+
+export default function DepartmentsPage(): ReactNode {
+ const [schoolId, setSchoolId] = useState("");
+ const { data: schools, loading: schoolsLoading } = useSchools();
+ const { data, loading, error } = useDepartments(schoolId || undefined);
+
+ return (
+ <>
+
+
+
+
+
+ {loading && }
+ {error && }
+ {!loading && !error && (
+ <>
+ {data.length === 0 ? (
+
+ ) : (
+
+ {data.map((dept) => (
+
+
+ {dept.name}
+
+ {dept.schoolName}
+ {dept.leaderName ?? "—"}
+ {dept.memberCount}
+ {dept.subject ?? "—"}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ >
+ );
+}
diff --git a/apps/admin-portal/src/app/admin/school/grades/insights/page.tsx b/apps/admin-portal/src/app/admin/school/grades/insights/page.tsx
new file mode 100644
index 0000000..3bb69d5
--- /dev/null
+++ b/apps/admin-portal/src/app/admin/school/grades/insights/page.tsx
@@ -0,0 +1,250 @@
+"use client";
+
+import { type ReactNode, useState, useMemo } from "react";
+import { useGrades, useGradeInsights } from "@/hooks/use-school";
+import {
+ PaperCard,
+ Select,
+ LoadingState,
+ ErrorState,
+ EmptyState,
+ Table,
+ TableRow,
+ TableCell,
+} from "@/components/ui";
+import { t } from "@/lib/i18n";
+
+interface SummaryCardProps {
+ label: string;
+ value: number | string;
+ suffix?: string;
+}
+
+function SummaryCard({ label, value, suffix }: SummaryCardProps): ReactNode {
+ return (
+
+
+ {label}
+
+
+ {value}
+ {suffix && (
+
+ {suffix}
+
+ )}
+
+
+ );
+}
+
+function formatPercent(rate: number): string {
+ return `${(rate * 100).toFixed(1)}%`;
+}
+
+export default function GradeInsightsPage(): ReactNode {
+ const { data: grades, loading: gradesLoading } = useGrades();
+ const [gradeId, setGradeId] = useState("");
+
+ const selectedGradeId = gradeId || (grades[0]?.id ?? null);
+ const { data, loading, error } = useGradeInsights(selectedGradeId);
+
+ const ranked = useMemo(() => {
+ if (!data) return [];
+ return [...data.classRankings].sort((a, b) => a.rank - b.rank);
+ }, [data]);
+
+ return (
+ <>
+
+
+
+
+ {loading && }
+ {error && }
+ {!loading && !error && !data && (
+
+
+
+ )}
+ {data && (
+
+ {/* 全校概览 */}
+
+
+ {t("admin.school.insights.summary")} — {data.gradeName}
+
+
+
+
+
+
+
+
+
+ {/* 作业时间线 */}
+
+
+ {t("admin.school.insights.homework")}
+
+ {data.homeworkTimeline.length === 0 ? (
+
+ ) : (
+
+ {data.homeworkTimeline.map((hw) => (
+
+
+ {hw.title}
+
+ {hw.className}
+
+ {hw.avgScore.toFixed(1)}
+
+
+ {hw.submittedCount}/{hw.totalCount}
+
+
+ {hw.dueDate}
+
+
+ ))}
+
+ )}
+
+
+ {/* 班级排名 */}
+
+
+ {t("admin.school.insights.rankings")}
+
+ {ranked.length === 0 ? (
+
+ ) : (
+
+ {ranked.map((cls) => {
+ const deltaColor =
+ cls.delta > 0
+ ? "var(--color-success)"
+ : cls.delta < 0
+ ? "var(--color-danger)"
+ : "var(--color-ink-muted)";
+ const deltaSign = cls.delta > 0 ? "+" : "";
+ return (
+
+
+ {cls.rank}
+
+
+ {cls.className}
+
+
+ {cls.avgScore.toFixed(1)}
+
+
+ {deltaSign}
+ {cls.delta.toFixed(1)}
+
+
+ );
+ })}
+
+ )}
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/admin-portal/src/app/admin/school/grades/page.tsx b/apps/admin-portal/src/app/admin/school/grades/page.tsx
new file mode 100644
index 0000000..6ca16f3
--- /dev/null
+++ b/apps/admin-portal/src/app/admin/school/grades/page.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { type ReactNode, useState, useMemo } from "react";
+import { useGrades, useSchools } from "@/hooks/use-school";
+import {
+ PaperCard,
+ Select,
+ LoadingState,
+ ErrorState,
+ EmptyState,
+ Table,
+ TableRow,
+ TableCell,
+} from "@/components/ui";
+import { t } from "@/lib/i18n";
+import type { GradeViewModel } from "@/types/view-models";
+
+export default function GradesPage(): ReactNode {
+ const [schoolId, setSchoolId] = useState("");
+ const { data: schools, loading: schoolsLoading } = useSchools();
+ const { data, loading, error } = useGrades(schoolId || undefined);
+
+ const sorted = useMemo(() => {
+ if (!data) return [];
+ return [...data].sort((a, b) => a.sortOrder - b.sortOrder);
+ }, [data]);
+
+ return (
+ <>
+
+
+
+
+
+ {loading && }
+ {error && }
+ {!loading && !error && (
+ <>
+ {sorted.length === 0 ? (
+
+ ) : (
+
+ {sorted.map((grade) => (
+
+
+ {grade.name}
+
+ {grade.schoolName}
+ {grade.directorName ?? "—"}
+ {grade.classCount}
+ {grade.studentCount}
+ {grade.sortOrder}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ >
+ );
+}
diff --git a/apps/admin-portal/src/app/admin/school/layout.tsx b/apps/admin-portal/src/app/admin/school/layout.tsx
new file mode 100644
index 0000000..c3ff719
--- /dev/null
+++ b/apps/admin-portal/src/app/admin/school/layout.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { type ReactNode } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { PageHeader } from "@/components/ui";
+import { t } from "@/lib/i18n";
+
+interface TabItem {
+ href: string;
+ label: string;
+}
+
+const TABS: TabItem[] = [
+ { href: "/admin/school/schools", label: t("admin.school.schools") },
+ { href: "/admin/school/grades", label: t("admin.school.grades") },
+ {
+ href: "/admin/school/grades/insights",
+ label: t("admin.school.gradeInsights"),
+ },
+ { href: "/admin/school/departments", label: t("admin.school.departments") },
+ {
+ href: "/admin/school/academic-year",
+ label: t("admin.school.academicYear"),
+ },
+];
+
+export default function SchoolLayout({
+ children,
+}: {
+ children: ReactNode;
+}): ReactNode {
+ const pathname = usePathname();
+
+ return (
+
+
+
+
+
+ {children}
+
+ );
+}
diff --git a/apps/admin-portal/src/app/admin/school/page.tsx b/apps/admin-portal/src/app/admin/school/page.tsx
new file mode 100644
index 0000000..414e86e
--- /dev/null
+++ b/apps/admin-portal/src/app/admin/school/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function SchoolPage(): never {
+ redirect("/admin/school/schools");
+}
diff --git a/apps/admin-portal/src/app/admin/school/schools/page.tsx b/apps/admin-portal/src/app/admin/school/schools/page.tsx
new file mode 100644
index 0000000..ebede30
--- /dev/null
+++ b/apps/admin-portal/src/app/admin/school/schools/page.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import { type ReactNode, useState, useMemo } from "react";
+import { useSchools } from "@/hooks/use-school";
+import {
+ PaperCard,
+ Input,
+ LoadingState,
+ ErrorState,
+ EmptyState,
+ Table,
+ TableRow,
+ TableCell,
+} from "@/components/ui";
+import { t } from "@/lib/i18n";
+import type { SchoolViewModel } from "@/types/view-models";
+
+export default function SchoolsPage(): ReactNode {
+ const { data, loading, error } = useSchools();
+ const [search, setSearch] = useState("");
+
+ const filtered = useMemo(() => {
+ if (!data) return [];
+ const q = search.trim().toLowerCase();
+ if (!q) return data;
+ return data.filter(
+ (s) =>
+ s.name.toLowerCase().includes(q) ||
+ s.code.toLowerCase().includes(q) ||
+ (s.principalName ?? "").toLowerCase().includes(q),
+ );
+ }, [data, search]);
+
+ return (
+ <>
+
+ setSearch(e.target.value)}
+ style={{ width: 240 }}
+ />
+
+
+
+ {loading && }
+ {error && }
+ {!loading && !error && (
+ <>
+ {filtered.length === 0 ? (
+
+ ) : (
+
+ {filtered.map((school) => (
+
+
+ {school.name}
+
+
+ {school.code}
+
+ {school.address}
+
+ {school.contactPhone}
+
+
+ {school.contactEmail}
+
+ {school.principalName ?? "—"}
+ {school.gradeCount}
+ {school.classCount}
+ {school.studentCount}
+ {school.teacherCount}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ >
+ );
+}
diff --git a/apps/admin-portal/src/app/admin/system/page.tsx b/apps/admin-portal/src/app/admin/system/page.tsx
index ff6ea61..b5758df 100644
--- a/apps/admin-portal/src/app/admin/system/page.tsx
+++ b/apps/admin-portal/src/app/admin/system/page.tsx
@@ -16,7 +16,19 @@ import {
ErrorState,
} from "@/components/ui";
import { t } from "@/lib/i18n";
-import type { SystemSettingsViewModel } from "@/types/view-models";
+import {
+ mockSecurityPolicy,
+ mockFileUploadConfig,
+ mockNotificationConfig,
+ mockBrandConfig,
+} from "@/mocks/fixtures";
+import type {
+ SystemSettingsViewModel,
+ SecurityPolicy,
+ FileUploadConfig,
+ NotificationConfig,
+ BrandConfig,
+} from "@/types/view-models";
export default function SystemPage(): ReactNode {
const { data, loading, error } = useSystemSettings();
@@ -24,6 +36,16 @@ export default function SystemPage(): ReactNode {
const [updateSettings] = useUpdateSystemSettings();
const [form, setForm] = useState(null);
const [saving, setSaving] = useState(false);
+ const [securityPolicy, setSecurityPolicy] =
+ useState(mockSecurityPolicy);
+ const [fileUploadConfig, setFileUploadConfig] =
+ useState(mockFileUploadConfig);
+ const [allowedTypesText, setAllowedTypesText] = useState(
+ mockFileUploadConfig.allowedTypes.join(", "),
+ );
+ const [notificationConfig, setNotificationConfig] =
+ useState(mockNotificationConfig);
+ const [brandConfig, setBrandConfig] = useState(mockBrandConfig);
useEffect(() => {
if (data && !form) {
@@ -209,6 +231,372 @@ export default function SystemPage(): ReactNode {
)}
+
+ {/* 安全策略 */}
+
+
+ {t("admin.system.security")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 文件上传配置 */}
+
+
+ {t("admin.system.fileUpload")}
+
+
+
+
+ {/* 通知配置 */}
+
+
+ {t("admin.system.notification")}
+
+
+
+
+ {/* 品牌配置 */}
+
+
+ {t("admin.system.brand")}
+
+
+
);
}
diff --git a/apps/admin-portal/src/components/admin-shell.tsx b/apps/admin-portal/src/components/admin-shell.tsx
index 1e2aa83..040682c 100644
--- a/apps/admin-portal/src/components/admin-shell.tsx
+++ b/apps/admin-portal/src/components/admin-shell.tsx
@@ -20,9 +20,14 @@ const NAV_ITEMS: NavItem[] = [
{ route: "/admin/permissions", label: t("admin.nav.permissions") },
{ route: "/admin/viewports", label: t("admin.nav.viewports") },
{ route: "/admin/organization", label: t("admin.nav.organization") },
+ { route: "/admin/school", label: t("admin.school.title") },
{ route: "/admin/classes", label: t("admin.nav.classes") },
{ route: "/admin/teachers", label: t("admin.nav.teachers") },
{ route: "/admin/students", label: t("admin.nav.students") },
+ { route: "/admin/announcements", label: t("admin.announcements.title") },
+ { route: "/admin/invitation-codes", label: t("admin.invitationCodes.title") },
+ { route: "/admin/files", label: t("admin.files.title") },
+ { route: "/admin/ai-settings", label: t("admin.aiSettings.title") },
{ route: "/admin/audit-logs", label: t("admin.nav.auditLogs") },
{ route: "/admin/system", label: t("admin.nav.system") },
];
diff --git a/apps/admin-portal/src/components/audit-logs-tabs.tsx b/apps/admin-portal/src/components/audit-logs-tabs.tsx
new file mode 100644
index 0000000..2f07250
--- /dev/null
+++ b/apps/admin-portal/src/components/audit-logs-tabs.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { type ReactNode } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { t } from "@/lib/i18n";
+
+interface TabItem {
+ label: string;
+ href: string;
+ exact: boolean;
+}
+
+const TABS: TabItem[] = [
+ { label: "操作日志", href: "/admin/audit-logs", exact: true },
+ {
+ label: t("admin.auditLogs.overview"),
+ href: "/admin/audit-logs/overview",
+ exact: false,
+ },
+ {
+ label: t("admin.auditLogs.dataChanges"),
+ href: "/admin/audit-logs/data-changes",
+ exact: false,
+ },
+ {
+ label: t("admin.auditLogs.loginLogs"),
+ href: "/admin/audit-logs/login-logs",
+ exact: false,
+ },
+];
+
+export function AuditLogsTabs(): ReactNode {
+ const pathname = usePathname();
+ return (
+
+ {TABS.map((tab) => {
+ const active = tab.exact
+ ? pathname === tab.href
+ : pathname.startsWith(tab.href);
+ return (
+
+ {tab.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/admin-portal/src/components/ui.test.tsx b/apps/admin-portal/src/components/ui.test.tsx
new file mode 100644
index 0000000..642abd5
--- /dev/null
+++ b/apps/admin-portal/src/components/ui.test.tsx
@@ -0,0 +1,267 @@
+/**
+ * 通用 UI 组件测试
+ */
+import { describe, expect, it, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import {
+ PaperCard,
+ PageHeader,
+ Button,
+ Input,
+ Select,
+ Badge,
+ Table,
+ TableRow,
+ TableCell,
+ EmptyState,
+ LoadingState,
+ ErrorState,
+ Pagination,
+} from "./ui";
+
+describe("PaperCard", () => {
+ it("渲染 children", () => {
+ render(