feat(admin-portal): 完成参考项目差距闭环 - 4 批次补齐 + Vitest 测试

差距分析闭环(workline §7-§8):
- P0:审计子模块 3 页 + 学校组织 7 页
- P1:系统设置 4 Card + 公告管理 + 邀请码管理
- P2:文件管理 + AI 配置
- P6:5 测试文件 / 69 用例全过

共享基础设施:
- permissions +11 权限点 +14 路由
- view-models +20 接口
- i18n +100 key
- graphql-client +25 operation
- fixtures/handlers +15 mock +20 handler

质量:typecheck + lint 零错误,vitest 69/69 通过,arch.db 已更新
This commit is contained in:
SpecialX
2026-07-13 13:05:29 +08:00
parent fad6e43b47
commit 7cf9aec20e
40 changed files with 6707 additions and 7 deletions

View File

@@ -0,0 +1,271 @@
"use client";
import { type ReactNode } from "react";
import {
useAiProviders,
useUpdateAiProvider,
useAiUsage,
} from "@/hooks/use-ai-settings";
import { useToast } from "@/providers/toast-provider";
import {
PageHeader,
PaperCard,
Button,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Badge,
} from "@/components/ui";
import { t } from "@/lib/i18n";
interface StatCardProps {
label: string;
value: number | string;
suffix?: string;
}
function StatCard({ label, value, suffix }: StatCardProps): ReactNode {
return (
<PaperCard className="p-4">
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{label}
</p>
<p
style={{
fontFamily: "var(--font-serif)",
fontSize: 28,
color: "var(--color-ink)",
margin: "8px 0 0 0",
}}
>
{value}
{suffix && (
<span
style={{
fontSize: 14,
color: "var(--color-ink-muted)",
marginLeft: 4,
}}
>
{suffix}
</span>
)}
</p>
</PaperCard>
);
}
export default function AiSettingsPage(): ReactNode {
const { data: providers, loading, error, refetch } = useAiProviders();
const { data: usage } = useAiUsage();
const [updateProvider, mutationState] = useUpdateAiProvider();
const { show } = useToast();
const handleToggle = async (id: string, isActive: boolean) => {
const res = await updateProvider(id, { isActive: !isActive });
if (res) {
show("success", `Provider 已${!isActive ? "启用" : "禁用"}`);
refetch();
} else if (mutationState.error) {
show("error", "操作失败", mutationState.error.message);
}
};
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.aiSettings.title")}
description="AI 服务商与用量管理"
/>
{/* AI Provider 列表 */}
<PaperCard className="p-4 mb-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.aiSettings.providers")}
</h3>
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && providers && (
<>
{providers.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.common.name"),
"类型",
t("admin.aiSettings.model"),
t("admin.aiSettings.isPublic"),
t("admin.aiSettings.isActive"),
"API Base",
t("admin.common.createdAt"),
]}
>
{providers.map((p) => (
<TableRow key={p.id}>
<TableCell>{p.name}</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{p.type}
</span>
</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{p.model}
</span>
</TableCell>
<TableCell>
<Badge status={p.isPublic ? "active" : "disabled"} />
</TableCell>
<TableCell>
<Button
variant={p.isActive ? "primary" : "secondary"}
onClick={() => handleToggle(p.id, p.isActive)}
disabled={mutationState.loading}
>
{p.isActive ? "启用" : "禁用"}
</Button>
</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-ink-muted)",
}}
>
{p.apiBase}
</span>
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(p.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
{/* AI 用量统计 */}
{usage && (
<>
<div className="grid grid-cols-3 gap-4 mb-6">
<StatCard
label={t("admin.aiSettings.totalRequests")}
value={usage.totalRequests}
/>
<StatCard
label={t("admin.aiSettings.totalTokens")}
value={usage.totalTokens}
/>
<StatCard
label={t("admin.aiSettings.totalCost")}
value={`$${usage.totalCost.toFixed(2)}`}
/>
</div>
{/* Provider 用量明细 */}
<PaperCard className="p-4 mb-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.aiSettings.usage")} - {t("admin.aiSettings.provider")}
</h3>
<Table
headers={[
t("admin.aiSettings.provider"),
t("admin.aiSettings.totalRequests"),
t("admin.aiSettings.totalTokens"),
t("admin.aiSettings.totalCost"),
]}
>
{usage.byProvider.map((row) => (
<TableRow key={row.providerName}>
<TableCell>{row.providerName}</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{row.requests}
</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{row.tokens}
</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
${row.cost.toFixed(2)}
</TableCell>
</TableRow>
))}
</Table>
</PaperCard>
{/* 7 天趋势 */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
7 {t("admin.auditLogs.trend")}
</h3>
<Table
headers={[
"日期",
t("admin.aiSettings.totalRequests"),
t("admin.aiSettings.totalTokens"),
]}
>
{usage.byDay.map((row) => (
<TableRow key={row.date}>
<TableCell style={{ whiteSpace: "nowrap" }}>
{row.date}
</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{row.requests}
</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{row.tokens}
</TableCell>
</TableRow>
))}
</Table>
</PaperCard>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,406 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import {
useAnnouncements,
useAnnouncementFilter,
useCreateAnnouncement,
usePublishAnnouncement,
useArchiveAnnouncement,
useToggleAnnouncementPin,
} from "@/hooks/use-announcements";
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";
import type { AnnouncementViewModel } from "@/types/view-models";
const AUDIENCE_OPTIONS = [
{ value: "all", label: "全部" },
{ value: "teachers", label: "教师" },
{ value: "students", label: "学生" },
{ value: "parents", label: "家长" },
] as const;
const audienceLabel: Record<string, string> = {
all: "全部",
teachers: "教师",
students: "学生",
parents: "家长",
};
export default function AnnouncementsPage(): ReactNode {
const { filter, setPage, setSearch, setStatus } = useAnnouncementFilter();
const { data, loading, error } = useAnnouncements(filter);
const { show } = useToast();
const [createAnnouncement] = useCreateAnnouncement();
const [publishAnnouncement] = usePublishAnnouncement();
const [archiveAnnouncement] = useArchiveAnnouncement();
const [togglePin] = useToggleAnnouncementPin();
const [searchInput, setSearchInput] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [formTitle, setFormTitle] = useState("");
const [formContent, setFormContent] = useState("");
const [formAudience, setFormAudience] = useState<string[]>(["all"]);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(timer);
}, [searchInput, setSearch]);
const openCreateModal = () => {
setFormTitle("");
setFormContent("");
setFormAudience(["all"]);
setModalOpen(true);
};
const toggleAudience = (value: string) => {
setFormAudience((prev) => {
if (value === "all") {
return ["all"];
}
const next = prev.filter((a) => a !== "all");
if (next.includes(value)) {
const filtered = next.filter((a) => a !== value);
return filtered.length === 0 ? ["all"] : filtered;
}
return [...next, value];
});
};
const handleCreate = async () => {
if (!formTitle.trim() || !formContent.trim()) {
show("warning", "请填写标题和内容");
return;
}
setSubmitting(true);
try {
await createAnnouncement({
title: formTitle,
content: formContent,
audience: formAudience,
});
show("success", "公告已创建");
setModalOpen(false);
window.location.reload();
} catch (err) {
show(
"error",
"创建失败",
err instanceof Error ? err.message : String(err),
);
} finally {
setSubmitting(false);
}
};
const handlePublish = async (ann: AnnouncementViewModel) => {
try {
await publishAnnouncement(ann.id);
show("success", "公告已发布");
window.location.reload();
} catch (err) {
show(
"error",
"操作失败",
err instanceof Error ? err.message : String(err),
);
}
};
const handleArchive = async (ann: AnnouncementViewModel) => {
try {
await archiveAnnouncement(ann.id);
show("success", "公告已归档");
window.location.reload();
} catch (err) {
show(
"error",
"操作失败",
err instanceof Error ? err.message : String(err),
);
}
};
const handleTogglePin = async (ann: AnnouncementViewModel) => {
try {
await togglePin(ann.id);
show("success", ann.isPinned ? "已取消置顶" : "已置顶");
window.location.reload();
} catch (err) {
show(
"error",
"操作失败",
err instanceof Error ? err.message : String(err),
);
}
};
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.announcements.title")}
description="管理全校公告、发布与归档"
actions={
<Button variant="primary" onClick={openCreateModal}>
{t("admin.announcements.new")}
</Button>
}
/>
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={filter.status ?? "all"}
onChange={(e) => setStatus(e.target.value)}
>
<option value="all">{t("admin.common.all")}</option>
<option value="draft">{t("admin.announcements.draft")}</option>
<option value="published">
{t("admin.announcements.published")}
</option>
<option value="archived">
{t("admin.announcements.archived")}
</option>
</Select>
</div>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && data && (
<>
{data.items.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<Table
headers={[
"标题",
t("admin.common.status"),
t("admin.announcements.audience"),
t("admin.announcements.author"),
t("admin.announcements.pin"),
t("admin.announcements.readCount"),
t("admin.announcements.publishedAt"),
t("admin.common.createdAt"),
t("admin.common.actions"),
]}
>
{data.items.map((ann) => (
<TableRow key={ann.id}>
<TableCell style={{ fontWeight: 500 }}>
{ann.title}
</TableCell>
<TableCell>
<Badge status={ann.status} />
</TableCell>
<TableCell>
{ann.audience
.map((a) => audienceLabel[a] ?? a)
.join(", ")}
</TableCell>
<TableCell>{ann.authorName}</TableCell>
<TableCell>
{ann.isPinned ? <Badge status="pinned" /> : "—"}
</TableCell>
<TableCell>
{ann.readCount}/{ann.totalCount}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{ann.publishedAt
? new Date(ann.publishedAt).toLocaleDateString(
"zh-CN",
)
: "—"}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(ann.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell>
<div className="flex gap-1">
{ann.status === "draft" && (
<Button
variant="primary"
onClick={() => handlePublish(ann)}
>
{t("admin.announcements.publish")}
</Button>
)}
{ann.status === "published" && (
<Button
variant="secondary"
onClick={() => handleArchive(ann)}
>
{t("admin.announcements.archive")}
</Button>
)}
<Button
variant="ghost"
onClick={() => handleTogglePin(ann)}
>
{ann.isPinned
? t("admin.announcements.unpin")
: t("admin.announcements.pin")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</Table>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
{modalOpen && (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
width: 480,
padding: 24,
background: "var(--bg-paper)",
borderRadius: 8,
border: "1px solid var(--color-rule)",
}}
>
<h2
style={{
fontFamily: "var(--font-serif)",
fontSize: 18,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.announcements.new")}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
</span>
<Input
type="text"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder="请输入公告标题"
/>
</div>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
</span>
<textarea
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
rows={5}
style={{
padding: "6px 10px",
border: "1px solid var(--color-rule)",
borderRadius: 4,
background: "var(--bg-paper)",
color: "var(--color-ink)",
fontSize: 13,
resize: "vertical",
}}
placeholder="请输入公告内容"
/>
</label>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.announcements.audience")}
</span>
<div className="flex gap-3">
{AUDIENCE_OPTIONS.map((opt) => (
<label
key={opt.value}
className="flex items-center gap-1"
style={{
fontSize: 13,
color: "var(--color-ink)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={formAudience.includes(opt.value)}
onChange={() => toggleAudience(opt.value)}
/>
{opt.label}
</label>
))}
</div>
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<Button variant="secondary" onClick={() => setModalOpen(false)}>
{t("admin.common.cancel")}
</Button>
<Button
variant="primary"
onClick={handleCreate}
disabled={submitting}
>
{submitting
? t("admin.common.loading")
: t("admin.common.save")}
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import {
useDataChangeLogs,
useDataChangeLogFilter,
exportDataChangeLogsCsv,
} from "@/hooks/use-data-change-logs";
import { useToast } from "@/providers/toast-provider";
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
import {
PageHeader,
PaperCard,
Input,
Select,
Button,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Pagination,
} from "@/components/ui";
import { t } from "@/lib/i18n";
export default function DataChangesPage(): ReactNode {
const { filter, setPage, setSearch, setTableName, setAction } =
useDataChangeLogFilter();
const { data, loading, error } = useDataChangeLogs(filter);
const { show } = useToast();
const [searchInput, setSearchInput] = useState("");
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(timer);
}, [searchInput, setSearch]);
const handleExport = () => {
if (!data || data.items.length === 0) {
show("warning", "暂无数据可导出");
return;
}
exportDataChangeLogsCsv(data.items);
show("success", "已导出 CSV");
};
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.auditLogs.dataChanges")}
description="数据表变更记录"
actions={
<Button variant="secondary" onClick={handleExport}>
{t("admin.auditLogs.exportCsv")}
</Button>
}
/>
<AuditLogsTabs />
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={filter.tableName ?? ""}
onChange={(e) => setTableName(e.target.value)}
>
<option value="">{t("admin.common.all")}</option>
<option value="users"></option>
<option value="roles"></option>
<option value="viewports"></option>
<option value="system_settings"></option>
</Select>
<Select
value={filter.action ?? ""}
onChange={(e) => setAction(e.target.value)}
>
<option value="">{t("admin.common.all")}</option>
<option value="create"></option>
<option value="update"></option>
<option value="delete"></option>
</Select>
</div>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && data && (
<>
{data.items.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<Table
headers={[
t("admin.auditLogs.time"),
t("admin.auditLogs.actor"),
t("admin.auditLogs.tableName"),
t("admin.auditLogs.action"),
t("admin.auditLogs.recordId"),
t("admin.auditLogs.beforeState"),
t("admin.auditLogs.afterState"),
t("admin.auditLogs.ip"),
]}
>
{data.items.map((log) => (
<TableRow key={log.id}>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(log.occurredAt).toLocaleString("zh-CN")}
</TableCell>
<TableCell>{log.actorName}</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.tableName}
</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{log.action}
</span>
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.recordId}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{log.beforeState ?? "—"}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{log.afterState ?? "—"}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.ip}
</TableCell>
</TableRow>
))}
</Table>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
</div>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import { useLoginLogs, useLoginLogFilter } from "@/hooks/use-login-logs";
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
import {
PageHeader,
PaperCard,
Input,
Select,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Pagination,
} from "@/components/ui";
import { t } from "@/lib/i18n";
export default function LoginLogsPage(): ReactNode {
const { filter, setPage, setSearch, setAction, setStatus } =
useLoginLogFilter();
const { data, loading, error } = useLoginLogs(filter);
const [searchInput, setSearchInput] = useState("");
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(timer);
}, [searchInput, setSearch]);
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.auditLogs.loginLogs")}
description="用户登录与登出记录"
/>
<AuditLogsTabs />
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={filter.action ?? ""}
onChange={(e) => setAction(e.target.value)}
>
<option value="">{t("admin.common.all")}</option>
<option value="signin"></option>
<option value="signout"></option>
<option value="signup"></option>
</Select>
<Select
value={filter.status ?? ""}
onChange={(e) => setStatus(e.target.value)}
>
<option value="">{t("admin.common.all")}</option>
<option value="success"></option>
<option value="failure"></option>
</Select>
</div>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && data && (
<>
{data.items.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<Table
headers={[
t("admin.auditLogs.time"),
t("admin.common.name"),
t("admin.auditLogs.loginAction"),
t("admin.auditLogs.loginStatus"),
t("admin.auditLogs.ip"),
t("admin.auditLogs.userAgent"),
t("admin.auditLogs.failureReason"),
]}
>
{data.items.map((log) => (
<TableRow key={log.id}>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(log.occurredAt).toLocaleString("zh-CN")}
</TableCell>
<TableCell>{log.userName}</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.action}
</TableCell>
<TableCell>
<span
style={{
display: "inline-block",
padding: "2px 8px",
fontSize: 11,
borderRadius: 10,
background:
log.status === "success"
? "var(--color-success)20"
: "var(--color-danger)20",
color:
log.status === "success"
? "var(--color-success)"
: "var(--color-danger)",
border: `1px solid ${
log.status === "success"
? "var(--color-success)40"
: "var(--color-danger)40"
}`,
}}
>
{log.status === "success" ? "成功" : "失败"}
</span>
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.ip}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{log.userAgent}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: log.failureReason
? "var(--color-danger)"
: "var(--color-ink-muted)",
}}
>
{log.failureReason ?? "—"}
</TableCell>
</TableRow>
))}
</Table>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
</div>
);
}

View File

@@ -0,0 +1,199 @@
"use client";
import { type ReactNode } from "react";
import {
useAuditOverviewStats,
useAuditTrend,
} from "@/hooks/use-audit-overview";
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
import {
PageHeader,
PaperCard,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
} from "@/components/ui";
import { t } from "@/lib/i18n";
interface StatCardProps {
label: string;
value: number | string;
}
function StatCard({ label, value }: StatCardProps): ReactNode {
return (
<PaperCard className="p-4">
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{label}
</p>
<p
style={{
fontFamily: "var(--font-serif)",
fontSize: 28,
color: "var(--color-ink)",
margin: "8px 0 0 0",
}}
>
{value}
</p>
</PaperCard>
);
}
export default function AuditOverviewPage(): ReactNode {
const { data: stats, loading, error } = useAuditOverviewStats();
const { data: trend } = useAuditTrend(7);
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.auditLogs.overview")}
description="审计事件统计与趋势"
/>
<AuditLogsTabs />
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{stats && (
<div className="flex flex-col gap-6">
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<StatCard
label={t("admin.auditLogs.totalEvents")}
value={stats.totalEvents}
/>
<StatCard
label={t("admin.auditLogs.todayEvents")}
value={stats.todayEvents}
/>
<StatCard
label={t("admin.auditLogs.uniqueActors")}
value={stats.uniqueActors}
/>
<StatCard
label={t("admin.auditLogs.failedActions")}
value={stats.failedActions}
/>
</div>
{/* 热门操作 & 热门资源 */}
<div className="grid grid-cols-2 gap-4">
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.auditLogs.topActions")}
</h3>
{stats.topActions.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table headers={["操作", "次数"]}>
{stats.topActions.map((item) => (
<TableRow key={item.action}>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{item.action}
</TableCell>
<TableCell style={{ textAlign: "right" }}>
{item.count}
</TableCell>
</TableRow>
))}
</Table>
)}
</PaperCard>
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.auditLogs.topResources")}
</h3>
{stats.topResources.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table headers={["资源", "次数"]}>
{stats.topResources.map((item) => (
<TableRow key={item.resource}>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{item.resource}
</TableCell>
<TableCell style={{ textAlign: "right" }}>
{item.count}
</TableCell>
</TableRow>
))}
</Table>
)}
</PaperCard>
</div>
{/* 7天趋势 */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.auditLogs.trend")}
</h3>
{!trend || trend.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.auditLogs.time"),
t("admin.auditLogs.action"),
t("admin.common.total"),
]}
>
{trend.map((point) => (
<TableRow key={`${point.date}-${point.action}`}>
<TableCell style={{ whiteSpace: "nowrap" }}>
{point.date}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{point.action}
</TableCell>
<TableCell style={{ textAlign: "right" }}>
{point.count}
</TableCell>
</TableRow>
))}
</Table>
)}
</PaperCard>
</div>
)}
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
exportAuditLogsCsv,
} from "@/hooks/use-audit-logs";
import { useToast } from "@/providers/toast-provider";
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
import {
PageHeader,
PaperCard,
@@ -55,6 +56,8 @@ export default function AuditLogsPage(): ReactNode {
}
/>
<AuditLogsTabs />
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input

View File

@@ -0,0 +1,334 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import {
useFiles,
useFileStats,
useDeleteFiles,
useFileFilter,
} from "@/hooks/use-files";
import { useToast } from "@/providers/toast-provider";
import {
PageHeader,
PaperCard,
Input,
Select,
Button,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Pagination,
} from "@/components/ui";
import { t } from "@/lib/i18n";
/** 格式化文件大小 */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export default function FilesPage(): ReactNode {
const { filter, setPage, setSearch, setMimeType, setEntityType } =
useFileFilter();
const { data, loading, error, refetch } = useFiles(filter);
const { data: stats } = useFileStats();
const [deleteFiles] = useDeleteFiles();
const { show } = useToast();
const [searchInput, setSearchInput] = useState("");
const [selected, setSelected] = useState<Set<string>>(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 (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.files.title")}
description="全校文件资源管理"
actions={
<Button
variant="danger"
onClick={handleBatchDelete}
disabled={selected.size === 0}
>
{t("admin.files.batchDelete")} ({selected.size})
</Button>
}
/>
{/* 统计摘要 */}
{stats && (
<div className="grid grid-cols-2 gap-4 mb-4">
<PaperCard className="p-4">
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{t("admin.files.totalFiles")}
</p>
<p
style={{
fontFamily: "var(--font-serif)",
fontSize: 28,
color: "var(--color-ink)",
margin: "8px 0 0 0",
}}
>
{stats.totalFiles}
</p>
</PaperCard>
<PaperCard className="p-4">
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{t("admin.files.totalSize")}
</p>
<p
style={{
fontFamily: "var(--font-serif)",
fontSize: 28,
color: "var(--color-ink)",
margin: "8px 0 0 0",
}}
>
{formatFileSize(stats.totalSize)}
</p>
</PaperCard>
</div>
)}
{/* 筛选栏 */}
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={filter.mimeType ?? ""}
onChange={(e) => setMimeType(e.target.value)}
>
<option value="">
{t("admin.common.all")}
{t("admin.files.mimeType")}
</option>
<option value="application/pdf">PDF</option>
<option value="image/jpeg">JPEG</option>
<option value="image/png">PNG</option>
<option value="application/vnd.openxmlformats-officedocument.wordprocessingml.document">
Word
</option>
<option value="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
Excel
</option>
<option value="application/vnd.openxmlformats-officedocument.presentationml.presentation">
PPT
</option>
<option value="video/mp4">MP4</option>
</Select>
<Select
value={filter.entityType ?? ""}
onChange={(e) => setEntityType(e.target.value)}
>
<option value="">
{t("admin.common.all")}
{t("admin.files.entityType")}
</option>
<option value="exam"></option>
<option value="lesson_plan"></option>
<option value="user_avatar"></option>
<option value="announcement"></option>
<option value="homework"></option>
</Select>
</div>
</PaperCard>
{/* 文件列表 */}
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && data && (
<>
{data.items.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<Table
headers={[
"",
t("admin.files.filename"),
t("admin.files.mimeType"),
t("admin.files.size"),
t("admin.files.entityType"),
t("admin.files.uploadedBy"),
t("admin.common.createdAt"),
]}
>
{data.items.map((file) => {
const isImage = file.mimeType.startsWith("image/");
const isPdf = file.mimeType === "application/pdf";
return (
<TableRow key={file.id}>
<TableCell style={{ width: 32 }}>
<input
type="checkbox"
checked={selected.has(file.id)}
onChange={() => toggleSelect(file.id)}
/>
</TableCell>
<TableCell>
{isImage ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<img
src={file.url}
alt={file.filename}
style={{
width: 32,
height: 32,
objectFit: "cover",
borderRadius: 4,
}}
/>
<span>{file.filename}</span>
</div>
) : isPdf ? (
<a
href={file.url}
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--color-accent)" }}
>
{file.filename}
</a>
) : (
<span>{file.filename}</span>
)}
</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{file.mimeType}
</span>
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{formatFileSize(file.size)}
</TableCell>
<TableCell>{file.entityType}</TableCell>
<TableCell>{file.uploadedByName}</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(file.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
</TableRow>
);
})}
</Table>
<div
style={{
marginTop: 12,
marginBottom: 8,
fontSize: 12,
color: "var(--color-ink-muted)",
}}
>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<input
type="checkbox"
checked={
data.items.length > 0 &&
selected.size === data.items.length
}
onChange={toggleSelectAll}
/>
( {selected.size}/{data.items.length})
</label>
</div>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
</div>
);
}

View File

@@ -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<Set<string>>(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<string[]>([]);
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 (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.invitationCodes.title")}
description="批量生成、管理与清理邀请码"
actions={
<Button variant="primary" onClick={openGenerateDialog}>
{t("admin.invitationCodes.generate")}
</Button>
}
/>
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center flex-wrap">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={filter.status ?? "all"}
onChange={(e) => setStatus(e.target.value)}
>
<option value="all">{t("admin.common.all")}</option>
<option value="unused">{t("admin.invitationCodes.unused")}</option>
<option value="used">{t("admin.invitationCodes.used")}</option>
<option value="expired">
{t("admin.invitationCodes.expired")}
</option>
</Select>
{selectedIds.size > 0 && (
<Button variant="danger" onClick={handleBatchDelete}>
{t("admin.common.delete")}{selectedIds.size}
</Button>
)}
</div>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && data && (
<>
{data.items.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<Table
headers={[
"",
t("admin.invitationCodes.code"),
t("admin.common.status"),
t("admin.common.role"),
t("admin.common.class"),
t("admin.common.email"),
t("admin.invitationCodes.batch"),
t("admin.invitationCodes.usedBy"),
t("admin.invitationCodes.usedAt"),
t("admin.common.createdAt"),
t("admin.invitationCodes.expiresAt"),
]}
>
{data.items.map((ic) => (
<TableRow key={ic.id}>
<TableCell style={{ width: 32 }}>
<input
type="checkbox"
checked={selectedIds.has(ic.id)}
onChange={() => toggleSelect(ic.id)}
aria-label={`select ${ic.code}`}
/>
</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
letterSpacing: "0.05em",
}}
>
{ic.code}
</span>
</TableCell>
<TableCell>
<Badge status={ic.status} />
</TableCell>
<TableCell>{ic.roleName ?? "—"}</TableCell>
<TableCell>{ic.className ?? "—"}</TableCell>
<TableCell>{ic.email ?? "—"}</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-ink-muted)",
}}
>
{ic.batchId}
</span>
</TableCell>
<TableCell>{ic.usedByName ?? "—"}</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{ic.usedAt
? new Date(ic.usedAt).toLocaleDateString("zh-CN")
: "—"}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(ic.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{ic.expiresAt
? new Date(ic.expiresAt).toLocaleDateString("zh-CN")
: "—"}
</TableCell>
</TableRow>
))}
</Table>
<div className="flex items-center justify-between mt-4">
<label
className="flex items-center gap-2"
style={{
fontSize: 12,
color: "var(--color-ink-muted)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={allChecked}
onChange={toggleSelectAll}
/>
</label>
</div>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={(p) => {
setSelectedIds(new Set());
setPage(p);
}}
/>
</>
)}
</>
)}
</PaperCard>
{/* 生成对话框 */}
{generateOpen && (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
width: 440,
padding: 24,
background: "var(--bg-paper)",
borderRadius: 8,
border: "1px solid var(--color-rule)",
}}
>
<h2
style={{
fontFamily: "var(--font-serif)",
fontSize: 18,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.invitationCodes.generate")}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.invitationCodes.generateCount")}1-1000
</span>
<Input
type="number"
min={1}
max={1000}
value={genCount}
onChange={(e) => setGenCount(Number(e.target.value) || 0)}
style={{ width: 120 }}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.invitationCodes.bindRole")}
</span>
<Select
value={genRoleId}
onChange={(e) => setGenRoleId(e.target.value)}
style={{ width: "100%" }}
>
<option value="">{t("admin.common.all")}</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</Select>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.invitationCodes.bindClass")}
</span>
<Select
value={genClassId}
onChange={(e) => setGenClassId(e.target.value)}
style={{ width: "100%" }}
>
<option value="">{t("admin.common.all")}</option>
{classes.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name}
</option>
))}
</Select>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.invitationCodes.bindEmail")}
</span>
<Input
type="email"
value={genEmail}
onChange={(e) => setGenEmail(e.target.value)}
placeholder="user@edu.test"
/>
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<Button
variant="secondary"
onClick={() => setGenerateOpen(false)}
>
{t("admin.common.cancel")}
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={generating}
>
{generating
? t("admin.common.loading")
: t("admin.invitationCodes.generate")}
</Button>
</div>
</div>
</div>
</div>
)}
{/* 生成结果对话框 */}
{resultOpen && (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
width: 480,
maxHeight: "80vh",
padding: 24,
background: "var(--bg-paper)",
borderRadius: 8,
border: "1px solid var(--color-rule)",
display: "flex",
flexDirection: "column",
}}
>
<h2
style={{
fontFamily: "var(--font-serif)",
fontSize: 18,
color: "var(--color-ink)",
marginBottom: 8,
}}
>
{t("admin.invitationCodes.generateSuccess", {
count: generatedCodes.length,
})}
</h2>
<p
style={{
fontSize: 12,
color: "var(--color-ink-muted)",
marginBottom: 12,
}}
>
{t("admin.invitationCodes.batch")}{generatedBatchId}
</p>
<div
style={{
border: "1px solid var(--color-rule)",
borderRadius: 4,
padding: 12,
background: "var(--bg-canvas, var(--bg-paper))",
overflowY: "auto",
flex: 1,
marginBottom: 12,
}}
>
<pre
style={{
margin: 0,
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "var(--color-ink)",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
{generatedCodes.join("\n")}
</pre>
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
}}
>
<Button variant="secondary" onClick={handleCopyCodes}>
</Button>
<Button
variant="primary"
onClick={() => {
setResultOpen(false);
window.location.reload();
}}
>
{t("admin.common.confirm")}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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 (
<span
style={{
display: "inline-block",
padding: "2px 8px",
fontSize: 11,
borderRadius: 10,
background: `${color}20`,
color,
border: `1px solid ${color}40`,
fontWeight: 500,
}}
>
{t("admin.school.currentYear")}
</span>
);
}
export default function AcademicYearPage(): ReactNode {
const { data, loading, error } = useAcademicYears();
return (
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && (
<>
{data.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
"学年",
t("admin.school.startDate"),
t("admin.school.endDate"),
"第一学期",
"第二学期",
t("admin.common.status"),
]}
>
{data.map((year: AcademicYearViewModel) => (
<TableRow key={year.id}>
<TableCell
style={{
fontWeight: year.isCurrent ? 600 : 500,
color: year.isCurrent
? "var(--color-accent)"
: "var(--color-ink)",
}}
>
{year.name}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{year.startDate}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{year.endDate}
</TableCell>
<TableCell
style={{
whiteSpace: "nowrap",
fontFamily: "var(--font-mono)",
fontSize: 12,
}}
>
{year.firstSemesterStart} ~ {year.firstSemesterEnd}
</TableCell>
<TableCell
style={{
whiteSpace: "nowrap",
fontFamily: "var(--font-mono)",
fontSize: 12,
}}
>
{year.secondSemesterStart} ~ {year.secondSemesterEnd}
</TableCell>
<TableCell>
{year.isCurrent ? <CurrentBadge /> : "—"}
</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
);
}

View File

@@ -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<string>("");
const { data: schools, loading: schoolsLoading } = useSchools();
const { data, loading, error } = useDepartments(schoolId || undefined);
return (
<>
<PaperCard className="p-4 mb-4">
<Select
value={schoolId}
onChange={(e) => setSchoolId(e.target.value)}
disabled={schoolsLoading}
>
<option value="">
{t("admin.common.all")}
{t("admin.common.school")}
</option>
{schools.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && (
<>
{data.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
"部门",
t("admin.common.school"),
t("admin.school.leader"),
t("admin.school.memberCount"),
t("admin.school.subject"),
]}
>
{data.map((dept) => (
<TableRow key={dept.id}>
<TableCell style={{ fontWeight: 500 }}>
{dept.name}
</TableCell>
<TableCell>{dept.schoolName}</TableCell>
<TableCell>{dept.leaderName ?? "—"}</TableCell>
<TableCell>{dept.memberCount}</TableCell>
<TableCell>{dept.subject ?? "—"}</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
</>
);
}

View File

@@ -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 (
<PaperCard className="p-4">
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{label}
</p>
<p
style={{
fontFamily: "var(--font-serif)",
fontSize: 28,
color: "var(--color-ink)",
margin: "8px 0 0 0",
}}
>
{value}
{suffix && (
<span
style={{
fontSize: 14,
color: "var(--color-ink-muted)",
marginLeft: 4,
}}
>
{suffix}
</span>
)}
</p>
</PaperCard>
);
}
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<string>("");
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 (
<>
<PaperCard className="p-4 mb-4">
<Select
value={selectedGradeId ?? ""}
onChange={(e) => setGradeId(e.target.value)}
disabled={gradesLoading || grades.length === 0}
>
{grades.length === 0 && <option value=""></option>}
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.name}{g.schoolName}
</option>
))}
</Select>
</PaperCard>
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && !data && (
<PaperCard className="p-4">
<EmptyState message={t("admin.common.empty")} />
</PaperCard>
)}
{data && (
<div className="flex flex-col gap-6">
{/* 全校概览 */}
<div>
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.school.insights.summary")} {data.gradeName}
</h3>
<div className="grid grid-cols-4 gap-4">
<SummaryCard
label={t("admin.dashboard.totalStudents")}
value={data.schoolWideSummary.totalStudents}
/>
<SummaryCard
label={t("admin.dashboard.totalClasses")}
value={data.schoolWideSummary.totalClasses}
/>
<SummaryCard
label={t("admin.school.insights.avgScore")}
value={data.schoolWideSummary.avgScore.toFixed(1)}
/>
<SummaryCard
label={t("admin.school.insights.passingRate")}
value={formatPercent(data.schoolWideSummary.passingRate)}
/>
</div>
</div>
{/* 作业时间线 */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.school.insights.homework")}
</h3>
{data.homeworkTimeline.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
"作业",
t("admin.common.class"),
t("admin.school.insights.avgScore"),
"提交情况",
t("admin.school.endDate"),
]}
>
{data.homeworkTimeline.map((hw) => (
<TableRow key={hw.homeworkId}>
<TableCell style={{ fontWeight: 500 }}>
{hw.title}
</TableCell>
<TableCell>{hw.className}</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{hw.avgScore.toFixed(1)}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
>
{hw.submittedCount}/{hw.totalCount}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{hw.dueDate}
</TableCell>
</TableRow>
))}
</Table>
)}
</PaperCard>
{/* 班级排名 */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.school.insights.rankings")}
</h3>
{ranked.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.school.insights.rank"),
t("admin.common.class"),
t("admin.school.insights.avgScore"),
t("admin.school.insights.delta"),
]}
>
{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 (
<TableRow key={cls.classId}>
<TableCell
style={{
fontFamily: "var(--font-serif)",
fontSize: 15,
fontWeight: 500,
}}
>
{cls.rank}
</TableCell>
<TableCell style={{ fontWeight: 500 }}>
{cls.className}
</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{cls.avgScore.toFixed(1)}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
color: deltaColor,
fontWeight: 500,
}}
>
{deltaSign}
{cls.delta.toFixed(1)}
</TableCell>
</TableRow>
);
})}
</Table>
)}
</PaperCard>
</div>
)}
</>
);
}

View File

@@ -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<string>("");
const { data: schools, loading: schoolsLoading } = useSchools();
const { data, loading, error } = useGrades(schoolId || undefined);
const sorted = useMemo<GradeViewModel[]>(() => {
if (!data) return [];
return [...data].sort((a, b) => a.sortOrder - b.sortOrder);
}, [data]);
return (
<>
<PaperCard className="p-4 mb-4">
<Select
value={schoolId}
onChange={(e) => setSchoolId(e.target.value)}
disabled={schoolsLoading}
>
<option value="">
{t("admin.common.all")}
{t("admin.common.school")}
</option>
{schools.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && (
<>
{sorted.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.common.grade"),
t("admin.common.school"),
t("admin.school.director"),
t("admin.common.class"),
t("admin.dashboard.totalStudents"),
t("admin.viewports.sortOrder"),
]}
>
{sorted.map((grade) => (
<TableRow key={grade.id}>
<TableCell style={{ fontWeight: 500 }}>
{grade.name}
</TableCell>
<TableCell>{grade.schoolName}</TableCell>
<TableCell>{grade.directorName ?? "—"}</TableCell>
<TableCell>{grade.classCount}</TableCell>
<TableCell>{grade.studentCount}</TableCell>
<TableCell>{grade.sortOrder}</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
</>
);
}

View File

@@ -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 (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.school.title")}
description="学校、年级、部门与学年学期管理"
/>
<nav
style={{
display: "flex",
gap: 4,
borderBottom: "1px solid var(--color-rule)",
marginBottom: 24,
}}
>
{TABS.map((tab) => {
const isActive =
pathname === tab.href ||
(tab.href !== "/admin/school/grades" &&
pathname.startsWith(tab.href));
// 年级管理 / 年级洞察 都在 /admin/school/grades 下,需要精确区分
const exactActive =
tab.href === "/admin/school/grades"
? pathname === "/admin/school/grades"
: isActive;
return (
<Link
key={tab.href}
href={tab.href}
style={{
padding: "8px 16px",
fontSize: 13,
color: exactActive
? "var(--color-ink)"
: "var(--color-ink-muted)",
textDecoration: "none",
borderBottom: exactActive
? "2px solid var(--color-accent)"
: "2px solid transparent",
marginBottom: "-1px",
fontWeight: exactActive ? 500 : 400,
}}
>
{tab.label}
</Link>
);
})}
</nav>
{children}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function SchoolPage(): never {
redirect("/admin/school/schools");
}

View File

@@ -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<SchoolViewModel[]>(() => {
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 (
<>
<PaperCard className="p-4 mb-4">
<Input
type="search"
placeholder={t("admin.common.search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 240 }}
/>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && (
<>
{filtered.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.common.school"),
t("admin.system.schoolCode"),
t("admin.system.address"),
t("admin.system.contactPhone"),
t("admin.system.contactEmail"),
t("admin.school.principal"),
t("admin.common.grade"),
t("admin.common.class"),
t("admin.dashboard.totalStudents"),
t("admin.dashboard.totalTeachers"),
]}
>
{filtered.map((school) => (
<TableRow key={school.id}>
<TableCell style={{ fontWeight: 500 }}>
{school.name}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{school.code}
</TableCell>
<TableCell>{school.address}</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{school.contactPhone}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{school.contactEmail}
</TableCell>
<TableCell>{school.principalName ?? "—"}</TableCell>
<TableCell>{school.gradeCount}</TableCell>
<TableCell>{school.classCount}</TableCell>
<TableCell>{school.studentCount}</TableCell>
<TableCell>{school.teacherCount}</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
</>
);
}

View File

@@ -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<SystemSettingsViewModel | null>(null);
const [saving, setSaving] = useState(false);
const [securityPolicy, setSecurityPolicy] =
useState<SecurityPolicy>(mockSecurityPolicy);
const [fileUploadConfig, setFileUploadConfig] =
useState<FileUploadConfig>(mockFileUploadConfig);
const [allowedTypesText, setAllowedTypesText] = useState(
mockFileUploadConfig.allowedTypes.join(", "),
);
const [notificationConfig, setNotificationConfig] =
useState<NotificationConfig>(mockNotificationConfig);
const [brandConfig, setBrandConfig] = useState<BrandConfig>(mockBrandConfig);
useEffect(() => {
if (data && !form) {
@@ -209,6 +231,372 @@ export default function SystemPage(): ReactNode {
</form>
)}
</PaperCard>
{/* 安全策略 */}
<PaperCard className="p-6 mt-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.system.security")}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div className="grid grid-cols-2 gap-4">
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.passwordMinLength")}
</span>
<Input
type="number"
min={4}
max={64}
value={securityPolicy.passwordMinLength}
onChange={(e) =>
setSecurityPolicy((prev) => ({
...prev,
passwordMinLength: Number(e.target.value),
}))
}
style={{ width: 120 }}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.sessionTimeout")}
</span>
<Input
type="number"
min={1}
value={securityPolicy.sessionTimeoutMinutes}
onChange={(e) =>
setSecurityPolicy((prev) => ({
...prev,
sessionTimeoutMinutes: Number(e.target.value),
}))
}
style={{ width: 120 }}
/>
</label>
</div>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={securityPolicy.passwordRequireSpecial}
onChange={(e) =>
setSecurityPolicy((prev) => ({
...prev,
passwordRequireSpecial: e.target.checked,
}))
}
/>
{t("admin.system.passwordRequireSpecial")}
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={securityPolicy.passwordRequireUppercase}
onChange={(e) =>
setSecurityPolicy((prev) => ({
...prev,
passwordRequireUppercase: e.target.checked,
}))
}
/>
{t("admin.system.passwordRequireUppercase")}
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={securityPolicy.forcePasswordChange}
onChange={(e) =>
setSecurityPolicy((prev) => ({
...prev,
forcePasswordChange: e.target.checked,
}))
}
/>
{t("admin.system.forcePasswordChange")}
</label>
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: 4,
}}
>
<Button
variant="primary"
onClick={() => show("success", t("admin.system.saveSuccess"))}
>
{t("admin.common.save")}
</Button>
</div>
</div>
</PaperCard>
{/* 文件上传配置 */}
<PaperCard className="p-6 mt-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.system.fileUpload")}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.maxFileSize")}
</span>
<Input
type="number"
min={1}
value={fileUploadConfig.maxFileSize}
onChange={(e) =>
setFileUploadConfig((prev) => ({
...prev,
maxFileSize: Number(e.target.value),
}))
}
style={{ width: 120 }}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.allowedTypes")}
</span>
<Input
type="text"
value={allowedTypesText}
onChange={(e) => setAllowedTypesText(e.target.value)}
placeholder="pdf, jpg, png, docx"
/>
</label>
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: 4,
}}
>
<Button
variant="primary"
onClick={() => {
setFileUploadConfig((prev) => ({
...prev,
allowedTypes: allowedTypesText
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}));
show("success", t("admin.system.saveSuccess"));
}}
>
{t("admin.common.save")}
</Button>
</div>
</div>
</PaperCard>
{/* 通知配置 */}
<PaperCard className="p-6 mt-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.system.notification")}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={notificationConfig.notifyNewUser}
onChange={(e) =>
setNotificationConfig((prev) => ({
...prev,
notifyNewUser: e.target.checked,
}))
}
/>
{t("admin.system.notifyNewUser")}
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={notificationConfig.notifyScheduleChange}
onChange={(e) =>
setNotificationConfig((prev) => ({
...prev,
notifyScheduleChange: e.target.checked,
}))
}
/>
{t("admin.system.notifyScheduleChange")}
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={notificationConfig.notifyAnnouncement}
onChange={(e) =>
setNotificationConfig((prev) => ({
...prev,
notifyAnnouncement: e.target.checked,
}))
}
/>
{t("admin.system.notifyAnnouncement")}
</label>
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: 4,
}}
>
<Button
variant="primary"
onClick={() => show("success", t("admin.system.saveSuccess"))}
>
{t("admin.common.save")}
</Button>
</div>
</div>
</PaperCard>
{/* 品牌配置 */}
<PaperCard className="p-6 mt-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.system.brand")}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.logoUrl")}
</span>
<Input
type="text"
value={brandConfig.logoUrl}
onChange={(e) =>
setBrandConfig((prev) => ({
...prev,
logoUrl: e.target.value,
}))
}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.primaryColor")}
</span>
<Input
type="color"
value={brandConfig.primaryColor}
onChange={(e) =>
setBrandConfig((prev) => ({
...prev,
primaryColor: e.target.value,
}))
}
style={{ width: 60, height: 32, padding: 2 }}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.schoolMotto")}
</span>
<Input
type="text"
value={brandConfig.schoolMotto}
onChange={(e) =>
setBrandConfig((prev) => ({
...prev,
schoolMotto: e.target.value,
}))
}
/>
</label>
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: 4,
}}
>
<Button
variant="primary"
onClick={() => show("success", t("admin.system.saveSuccess"))}
>
{t("admin.common.save")}
</Button>
</div>
</div>
</PaperCard>
</div>
);
}

View File

@@ -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") },
];

View File

@@ -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 (
<div
className="flex gap-1 mb-4"
style={{ borderBottom: "1px solid var(--color-rule)" }}
>
{TABS.map((tab) => {
const active = tab.exact
? pathname === tab.href
: pathname.startsWith(tab.href);
return (
<Link
key={tab.href}
href={tab.href}
style={{
padding: "8px 16px",
fontSize: 13,
color: active ? "var(--color-accent)" : "var(--color-ink-muted)",
borderBottom: active
? "2px solid var(--color-accent)"
: "2px solid transparent",
textDecoration: "none",
fontFamily: active ? "var(--font-serif)" : "var(--font-sans)",
}}
>
{tab.label}
</Link>
);
})}
</div>
);
}

View File

@@ -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(<PaperCard></PaperCard>);
expect(screen.getByText("内容")).toBeInTheDocument();
});
it("支持自定义 className", () => {
const { container } = render(
<PaperCard className="custom-class"></PaperCard>,
);
expect(container.firstChild).toHaveClass("custom-class");
});
});
describe("PageHeader", () => {
it("渲染标题", () => {
render(<PageHeader title="用户管理" />);
expect(screen.getByText("用户管理")).toBeInTheDocument();
});
it("渲染描述", () => {
render(<PageHeader title="用户管理" description="管理所有用户" />);
expect(screen.getByText("管理所有用户")).toBeInTheDocument();
});
it("渲染操作按钮", () => {
render(<PageHeader title="用户管理" actions={<button></button>} />);
expect(screen.getByText("新建")).toBeInTheDocument();
});
});
describe("Button", () => {
it("默认 secondary variant", () => {
render(<Button></Button>);
expect(screen.getByText("点击")).toBeInTheDocument();
});
it("点击触发 onClick", () => {
const onClick = vi.fn();
render(<Button onClick={onClick}></Button>);
fireEvent.click(screen.getByText("点击"));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("disabled 时不触发 onClick", () => {
const onClick = vi.fn();
render(
<Button onClick={onClick} disabled>
</Button>,
);
fireEvent.click(screen.getByText("点击"));
expect(onClick).not.toHaveBeenCalled();
});
it("支持所有 variant", () => {
const { rerender } = render(<Button variant="primary"></Button>);
expect(screen.getByText("主按钮")).toBeInTheDocument();
rerender(<Button variant="danger"></Button>);
expect(screen.getByText("危险按钮")).toBeInTheDocument();
rerender(<Button variant="ghost"></Button>);
expect(screen.getByText("幽灵按钮")).toBeInTheDocument();
});
});
describe("Input", () => {
it("渲染输入框并支持输入", () => {
render(<Input placeholder="请输入" />);
const input = screen.getByPlaceholderText("请输入");
fireEvent.change(input, { target: { value: "测试内容" } });
expect(input).toHaveValue("测试内容");
});
});
describe("Select", () => {
it("渲染下拉框和选项", () => {
render(
<Select>
<option value="a">A</option>
<option value="b">B</option>
</Select>,
);
expect(screen.getByText("选项A")).toBeInTheDocument();
expect(screen.getByText("选项B")).toBeInTheDocument();
});
});
describe("Badge", () => {
it("渲染 active 状态标签", () => {
render(<Badge status="active" />);
expect(screen.getByText("启用")).toBeInTheDocument();
});
it("渲染 locked 状态标签", () => {
render(<Badge status="locked" />);
expect(screen.getByText("锁定")).toBeInTheDocument();
});
it("渲染公告相关状态", () => {
const { rerender } = render(<Badge status="draft" />);
expect(screen.getByText("草稿")).toBeInTheDocument();
rerender(<Badge status="published" />);
expect(screen.getByText("已发布")).toBeInTheDocument();
rerender(<Badge status="archived" />);
expect(screen.getByText("已归档")).toBeInTheDocument();
});
it("渲染邀请码状态", () => {
const { rerender } = render(<Badge status="unused" />);
expect(screen.getByText("未使用")).toBeInTheDocument();
rerender(<Badge status="used" />);
expect(screen.getByText("已使用")).toBeInTheDocument();
rerender(<Badge status="expired" />);
expect(screen.getByText("已过期")).toBeInTheDocument();
});
it("未知状态显示原始值", () => {
render(<Badge status="unknown-status" />);
expect(screen.getByText("unknown-status")).toBeInTheDocument();
});
});
describe("Table", () => {
it("渲染表头和数据行", () => {
render(
<Table headers={["姓名", "邮箱"]}>
<TableRow>
<TableCell></TableCell>
<TableCell>zhangsan@test.com</TableCell>
</TableRow>
</Table>,
);
expect(screen.getByText("姓名")).toBeInTheDocument();
expect(screen.getByText("邮箱")).toBeInTheDocument();
expect(screen.getByText("张三")).toBeInTheDocument();
expect(screen.getByText("zhangsan@test.com")).toBeInTheDocument();
});
});
describe("EmptyState", () => {
it("渲染空状态消息", () => {
render(<EmptyState message="暂无数据" />);
expect(screen.getByText("暂无数据")).toBeInTheDocument();
});
});
describe("LoadingState", () => {
it("渲染加载中文案", () => {
render(<LoadingState />);
expect(screen.getByText("加载中...")).toBeInTheDocument();
});
});
describe("ErrorState", () => {
it("渲染错误消息", () => {
render(<ErrorState message="加载失败" />);
expect(screen.getByText("加载失败")).toBeInTheDocument();
});
it("提供 onRetry 时渲染重试按钮", () => {
const onRetry = vi.fn();
render(<ErrorState message="加载失败" onRetry={onRetry} />);
const retryBtn = screen.getByText("重试");
fireEvent.click(retryBtn);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it("不提供 onRetry 时不渲染重试按钮", () => {
render(<ErrorState message="加载失败" />);
expect(screen.queryByText("重试")).not.toBeInTheDocument();
});
});
describe("Pagination", () => {
it("渲染总数和页码", () => {
render(
<Pagination
page={2}
pageSize={20}
total={45}
hasNext={true}
onPageChange={() => {}}
/>,
);
expect(screen.getByText(/共 45 条/)).toBeInTheDocument();
expect(screen.getByText(/第 2\/3 页/)).toBeInTheDocument();
});
it("第一页禁用上一页按钮", () => {
render(
<Pagination
page={1}
pageSize={20}
total={45}
hasNext={true}
onPageChange={() => {}}
/>,
);
expect(screen.getByText("上一页")).toBeDisabled();
expect(screen.getByText("下一页")).not.toBeDisabled();
});
it("最后一页禁用下一页按钮", () => {
render(
<Pagination
page={3}
pageSize={20}
total={45}
hasNext={false}
onPageChange={() => {}}
/>,
);
expect(screen.getByText("下一页")).toBeDisabled();
expect(screen.getByText("上一页")).not.toBeDisabled();
});
it("点击上一页/下一页触发 onPageChange", () => {
const onPageChange = vi.fn();
render(
<Pagination
page={2}
pageSize={20}
total={45}
hasNext={true}
onPageChange={onPageChange}
/>,
);
fireEvent.click(screen.getByText("上一页"));
expect(onPageChange).toHaveBeenCalledWith(1);
fireEvent.click(screen.getByText("下一页"));
expect(onPageChange).toHaveBeenCalledWith(3);
});
it("total 为 0 时显示第 1/1 页", () => {
render(
<Pagination
page={1}
pageSize={20}
total={0}
hasNext={false}
onPageChange={() => {}}
/>,
);
expect(screen.getByText(/第 1\/1 页/)).toBeInTheDocument();
});
});

View File

@@ -180,6 +180,13 @@ export function Badge({ status }: { status: string }): ReactNode {
healthy: "var(--color-success)",
degraded: "var(--color-warning)",
down: "var(--color-danger)",
draft: "var(--color-warning)",
published: "var(--color-success)",
archived: "var(--color-ink-muted)",
unused: "var(--color-accent)",
used: "var(--color-success)",
expired: "var(--color-danger)",
pinned: "var(--color-accent)",
};
const labelMap: Record<string, string> = {
active: "启用",
@@ -188,6 +195,13 @@ export function Badge({ status }: { status: string }): ReactNode {
healthy: "健康",
degraded: "降级",
down: "异常",
draft: "草稿",
published: "已发布",
archived: "已归档",
unused: "未使用",
used: "已使用",
expired: "已过期",
pinned: "置顶",
};
const color = colorMap[status] ?? "var(--color-ink-muted)";
return (

View File

@@ -0,0 +1,59 @@
/**
* AI 配置 Hooks
*
* contract §2.4aiProviders / updateAiProvider / aiUsage
*/
import { useCallback } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
AI_PROVIDERS_QUERY,
UPDATE_AI_PROVIDER_MUTATION,
AI_USAGE_QUERY,
} from "@/lib/graphql-client";
import type {
AiProviderViewModel,
AiUsageViewModel,
} from "@/types/view-models";
interface AiProvidersResponse {
aiProviders: AiProviderViewModel[];
}
interface UpdateAiProviderResponse {
updateAiProvider: { id: string; isActive: boolean };
}
interface AiUsageResponse {
aiUsage: AiUsageViewModel;
}
export function useAiProviders() {
const result = useGraphQuery<AiProvidersResponse>(AI_PROVIDERS_QUERY);
return {
...result,
data: result.data?.aiProviders ?? null,
};
}
export function useUpdateAiProvider() {
const [run, state] = useGraphMutation<
UpdateAiProviderResponse,
{ id: string; input: { isActive: boolean } }
>(UPDATE_AI_PROVIDER_MUTATION);
const update = useCallback(
async (id: string, input: { isActive: boolean }) => {
const res = await run({ id, input });
return res.data?.updateAiProvider ?? null;
},
[run],
);
return [update, state] as const;
}
export function useAiUsage() {
const result = useGraphQuery<AiUsageResponse>(AI_USAGE_QUERY);
return {
...result,
data: result.data?.aiUsage ?? null,
};
}

View File

@@ -0,0 +1,128 @@
/**
* 公告管理 Hooks
*
* contract §2.4adminAnnouncements / createAnnouncement / publishAnnouncement / archiveAnnouncement / toggleAnnouncementPin
*/
import { useCallback, useState } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
ADMIN_ANNOUNCEMENTS_QUERY,
CREATE_ANNOUNCEMENT_MUTATION,
PUBLISH_ANNOUNCEMENT_MUTATION,
ARCHIVE_ANNOUNCEMENT_MUTATION,
TOGGLE_ANNOUNCEMENT_PIN_MUTATION,
} from "@/lib/graphql-client";
import type {
AnnouncementViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface AdminAnnouncementsResponse {
adminAnnouncements: PaginatedResult<AnnouncementViewModel>;
}
interface AnnouncementMutationResponse {
createAnnouncement?: { id: string; title: string; status: string };
publishAnnouncement?: { id: string; status: string };
archiveAnnouncement?: { id: string; status: string };
toggleAnnouncementPin?: { id: string; isPinned: boolean };
}
interface CreateAnnouncementInput {
title: string;
content: string;
audience?: string[];
}
export function useAnnouncements(filter: Partial<ListFilter>) {
const result = useGraphQuery<AdminAnnouncementsResponse>(
ADMIN_ANNOUNCEMENTS_QUERY,
{ filter },
);
return {
...result,
data: result.data?.adminAnnouncements ?? null,
};
}
export function useAnnouncementFilter(initial?: Partial<ListFilter>) {
const [filter, setFilter] = useState<Partial<ListFilter>>({
page: 1,
pageSize: 20,
...initial,
});
const setPage = useCallback(
(page: number) => setFilter((f) => ({ ...f, page })),
[],
);
const setSearch = useCallback(
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
[],
);
const setStatus = useCallback(
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
[],
);
return { filter, setFilter, setPage, setSearch, setStatus };
}
export function useCreateAnnouncement() {
const [run, state] = useGraphMutation<
AnnouncementMutationResponse,
{ input: CreateAnnouncementInput }
>(CREATE_ANNOUNCEMENT_MUTATION);
const create = useCallback(
async (input: CreateAnnouncementInput) => {
const res = await run({ input });
return res.data?.createAnnouncement ?? null;
},
[run],
);
return [create, state] as const;
}
export function usePublishAnnouncement() {
const [run, state] = useGraphMutation<
AnnouncementMutationResponse,
{ id: string }
>(PUBLISH_ANNOUNCEMENT_MUTATION);
const publish = useCallback(
async (id: string) => {
const res = await run({ id });
return res.data?.publishAnnouncement ?? null;
},
[run],
);
return [publish, state] as const;
}
export function useArchiveAnnouncement() {
const [run, state] = useGraphMutation<
AnnouncementMutationResponse,
{ id: string }
>(ARCHIVE_ANNOUNCEMENT_MUTATION);
const archive = useCallback(
async (id: string) => {
const res = await run({ id });
return res.data?.archiveAnnouncement ?? null;
},
[run],
);
return [archive, state] as const;
}
export function useToggleAnnouncementPin() {
const [run, state] = useGraphMutation<
AnnouncementMutationResponse,
{ id: string }
>(TOGGLE_ANNOUNCEMENT_PIN_MUTATION);
const toggle = useCallback(
async (id: string) => {
const res = await run({ id });
return res.data?.toggleAnnouncementPin ?? null;
},
[run],
);
return [toggle, state] as const;
}

View File

@@ -0,0 +1,39 @@
/**
* 审计总览 Hooks
*
* contract §2.4auditOverviewStats / auditTrend
*/
import { useGraphQuery } from "./use-graphql";
import {
AUDIT_OVERVIEW_STATS_QUERY,
AUDIT_TREND_QUERY,
} from "@/lib/graphql-client";
import type { AuditOverviewStats, AuditTrendPoint } from "@/types/view-models";
interface AuditOverviewStatsResponse {
auditOverviewStats: AuditOverviewStats;
}
interface AuditTrendResponse {
auditTrend: AuditTrendPoint[];
}
export function useAuditOverviewStats() {
const result = useGraphQuery<AuditOverviewStatsResponse>(
AUDIT_OVERVIEW_STATS_QUERY,
);
return {
...result,
data: result.data?.auditOverviewStats ?? null,
};
}
export function useAuditTrend(days?: number) {
const result = useGraphQuery<AuditTrendResponse>(AUDIT_TREND_QUERY, {
days,
});
return {
...result,
data: result.data?.auditTrend ?? null,
};
}

View File

@@ -0,0 +1,97 @@
/**
* 数据变更日志 Hooks
*
* contract §2.4dataChangeLogs
*/
import { useCallback, useState } from "react";
import { useGraphQuery } from "./use-graphql";
import { DATA_CHANGE_LOGS_QUERY } from "@/lib/graphql-client";
import type {
DataChangeLogViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface DataChangeLogsResponse {
dataChangeLogs: PaginatedResult<DataChangeLogViewModel>;
}
interface DataChangeLogFilter extends Partial<ListFilter> {
tableName?: string;
action?: string;
startDate?: number;
endDate?: number;
}
export function useDataChangeLogs(filter: DataChangeLogFilter) {
const result = useGraphQuery<DataChangeLogsResponse>(DATA_CHANGE_LOGS_QUERY, {
filter,
});
return {
...result,
data: result.data?.dataChangeLogs ?? null,
};
}
export function useDataChangeLogFilter(initial?: Partial<DataChangeLogFilter>) {
const [filter, setFilter] = useState<DataChangeLogFilter>({
page: 1,
pageSize: 20,
...initial,
});
const setPage = useCallback(
(page: number) => setFilter((f) => ({ ...f, page })),
[],
);
const setSearch = useCallback(
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
[],
);
const setTableName = useCallback(
(tableName: string) =>
setFilter((f) => ({ ...f, tableName: tableName || undefined, page: 1 })),
[],
);
const setAction = useCallback(
(action: string) =>
setFilter((f) => ({ ...f, action: action || undefined, page: 1 })),
[],
);
return { filter, setFilter, setPage, setSearch, setTableName, setAction };
}
/** 数据变更日志导出 CSV */
export function exportDataChangeLogsCsv(logs: DataChangeLogViewModel[]): void {
const headers = [
"时间",
"操作人",
"数据表",
"操作",
"记录ID",
"变更前",
"变更后",
"IP",
];
const rows = logs.map((l) => [
new Date(l.occurredAt).toLocaleString("zh-CN"),
l.actorName,
l.tableName,
l.action,
l.recordId,
l.beforeState ?? "",
l.afterState ?? "",
l.ip,
]);
const csv = [headers, ...rows]
.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
)
.join("\n");
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `data-change-logs-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,102 @@
/**
* 文件管理 Hooks
*
* contract §2.4adminFiles / adminFileStats / deleteFiles
*/
import { useCallback, useState } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
ADMIN_FILES_QUERY,
ADMIN_FILE_STATS_QUERY,
DELETE_FILES_MUTATION,
} from "@/lib/graphql-client";
import type {
FileViewModel,
FileStats,
PaginatedResult,
} from "@/types/view-models";
/** 文件列表筛选参数 */
export interface FileFilter {
page?: number;
pageSize?: number;
search?: string;
mimeType?: string;
entityType?: string;
}
interface AdminFilesResponse {
adminFiles: PaginatedResult<FileViewModel>;
}
interface AdminFileStatsResponse {
adminFileStats: FileStats;
}
interface DeleteFilesResponse {
deleteFiles: boolean;
}
export function useFiles(filter: FileFilter) {
const result = useGraphQuery<AdminFilesResponse>(ADMIN_FILES_QUERY, {
filter,
});
return {
...result,
data: result.data?.adminFiles ?? null,
};
}
export function useFileStats() {
const result = useGraphQuery<AdminFileStatsResponse>(ADMIN_FILE_STATS_QUERY);
return {
...result,
data: result.data?.adminFileStats ?? null,
};
}
export function useDeleteFiles() {
const [run, state] = useGraphMutation<DeleteFilesResponse, { ids: string[] }>(
DELETE_FILES_MUTATION,
);
const deleteFiles = useCallback(
async (ids: string[]) => {
const res = await run({ ids });
return res.data?.deleteFiles ?? false;
},
[run],
);
return [deleteFiles, state] as const;
}
/** 文件筛选状态管理 */
export function useFileFilter(initial?: FileFilter) {
const [filter, setFilter] = useState<FileFilter>({
page: 1,
pageSize: 20,
...initial,
});
const setPage = useCallback(
(page: number) => setFilter((f) => ({ ...f, page })),
[],
);
const setSearch = useCallback(
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
[],
);
const setMimeType = useCallback(
(mimeType: string) =>
setFilter((f) => ({ ...f, mimeType: mimeType || undefined, page: 1 })),
[],
);
const setEntityType = useCallback(
(entityType: string) =>
setFilter((f) => ({
...f,
entityType: entityType || undefined,
page: 1,
})),
[],
);
return { filter, setFilter, setPage, setSearch, setMimeType, setEntityType };
}

View File

@@ -0,0 +1,99 @@
/**
* 邀请码管理 Hooks
*
* contract §2.4adminInvitationCodes / generateInvitationCodes / deleteInvitationCodes
*/
import { useCallback, useState } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
ADMIN_INVITATION_CODES_QUERY,
GENERATE_INVITATION_CODES_MUTATION,
DELETE_INVITATION_CODES_MUTATION,
} from "@/lib/graphql-client";
import type {
InvitationCodeViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface AdminInvitationCodesResponse {
adminInvitationCodes: PaginatedResult<InvitationCodeViewModel>;
}
interface GenerateInvitationCodesResponse {
generateInvitationCodes: { codes: string[]; batchId: string };
}
interface DeleteInvitationCodesResponse {
deleteInvitationCodes: boolean;
}
interface GenerateInvitationCodesInput {
count: number;
roleId?: string;
classId?: string;
email?: string;
expiresAt?: number;
}
export function useInvitationCodes(filter: Partial<ListFilter>) {
const result = useGraphQuery<AdminInvitationCodesResponse>(
ADMIN_INVITATION_CODES_QUERY,
{ filter },
);
return {
...result,
data: result.data?.adminInvitationCodes ?? null,
};
}
export function useInvitationCodeFilter(initial?: Partial<ListFilter>) {
const [filter, setFilter] = useState<Partial<ListFilter>>({
page: 1,
pageSize: 20,
...initial,
});
const setPage = useCallback(
(page: number) => setFilter((f) => ({ ...f, page })),
[],
);
const setSearch = useCallback(
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
[],
);
const setStatus = useCallback(
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
[],
);
return { filter, setFilter, setPage, setSearch, setStatus };
}
export function useGenerateInvitationCodes() {
const [run, state] = useGraphMutation<
GenerateInvitationCodesResponse,
{ input: GenerateInvitationCodesInput }
>(GENERATE_INVITATION_CODES_MUTATION);
const generate = useCallback(
async (input: GenerateInvitationCodesInput) => {
const res = await run({ input });
return res.data?.generateInvitationCodes ?? null;
},
[run],
);
return [generate, state] as const;
}
export function useDeleteInvitationCodes() {
const [run, state] = useGraphMutation<
DeleteInvitationCodesResponse,
{ ids: string[] }
>(DELETE_INVITATION_CODES_MUTATION);
const remove = useCallback(
async (ids: string[]) => {
const res = await run({ ids });
return res.data?.deleteInvitationCodes ?? false;
},
[run],
);
return [remove, state] as const;
}

View File

@@ -0,0 +1,61 @@
/**
* 登录日志 Hooks
*
* contract §2.4loginLogs
*/
import { useCallback, useState } from "react";
import { useGraphQuery } from "./use-graphql";
import { LOGIN_LOGS_QUERY } from "@/lib/graphql-client";
import type {
LoginLogViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface LoginLogsResponse {
loginLogs: PaginatedResult<LoginLogViewModel>;
}
interface LoginLogFilter extends Partial<ListFilter> {
action?: string;
status?: string;
startDate?: number;
endDate?: number;
}
export function useLoginLogs(filter: LoginLogFilter) {
const result = useGraphQuery<LoginLogsResponse>(LOGIN_LOGS_QUERY, {
filter,
});
return {
...result,
data: result.data?.loginLogs ?? null,
};
}
export function useLoginLogFilter(initial?: Partial<LoginLogFilter>) {
const [filter, setFilter] = useState<LoginLogFilter>({
page: 1,
pageSize: 20,
...initial,
});
const setPage = useCallback(
(page: number) => setFilter((f) => ({ ...f, page })),
[],
);
const setSearch = useCallback(
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
[],
);
const setAction = useCallback(
(action: string) =>
setFilter((f) => ({ ...f, action: action || undefined, page: 1 })),
[],
);
const setStatus = useCallback(
(status: string) =>
setFilter((f) => ({ ...f, status: status || undefined, page: 1 })),
[],
);
return { filter, setFilter, setPage, setSearch, setAction, setStatus };
}

View File

@@ -0,0 +1,99 @@
/**
* 学校组织管理 Hooks
*
* contract §2.4adminSchools / adminGrades / adminGradeInsights /
* adminDepartments / adminAcademicYears
*/
import { useGraphQuery } from "./use-graphql";
import {
ADMIN_SCHOOLS_QUERY,
ADMIN_GRADES_QUERY,
ADMIN_GRADE_INSIGHTS_QUERY,
ADMIN_DEPARTMENTS_QUERY,
ADMIN_ACADEMIC_YEARS_QUERY,
} from "@/lib/graphql-client";
import type {
SchoolViewModel,
GradeViewModel,
GradeInsightsViewModel,
DepartmentViewModel,
AcademicYearViewModel,
} from "@/types/view-models";
interface AdminSchoolsResponse {
adminSchools: SchoolViewModel[];
}
interface AdminGradesResponse {
adminGrades: GradeViewModel[];
}
interface AdminGradeInsightsResponse {
adminGradeInsights: GradeInsightsViewModel;
}
interface AdminDepartmentsResponse {
adminDepartments: DepartmentViewModel[];
}
interface AdminAcademicYearsResponse {
adminAcademicYears: AcademicYearViewModel[];
}
/** 学校列表 */
export function useSchools() {
const result = useGraphQuery<AdminSchoolsResponse>(ADMIN_SCHOOLS_QUERY);
return {
...result,
data: result.data?.adminSchools ?? [],
};
}
/** 年级列表(可按学校筛选) */
export function useGrades(schoolId?: string) {
const variables = schoolId ? { schoolId } : {};
const result = useGraphQuery<AdminGradesResponse>(
ADMIN_GRADES_QUERY,
variables,
);
return {
...result,
data: result.data?.adminGrades ?? [],
};
}
/** 年级洞察(按年级 ID */
export function useGradeInsights(gradeId: string | null | undefined) {
const result = useGraphQuery<AdminGradeInsightsResponse>(
ADMIN_GRADE_INSIGHTS_QUERY,
gradeId ? { gradeId } : {},
);
return {
...result,
data: result.data?.adminGradeInsights ?? null,
};
}
/** 部门/教研组列表(可按学校筛选) */
export function useDepartments(schoolId?: string) {
const variables = schoolId ? { schoolId } : {};
const result = useGraphQuery<AdminDepartmentsResponse>(
ADMIN_DEPARTMENTS_QUERY,
variables,
);
return {
...result,
data: result.data?.adminDepartments ?? [],
};
}
/** 学年/学期列表 */
export function useAcademicYears() {
const result = useGraphQuery<AdminAcademicYearsResponse>(
ADMIN_ACADEMIC_YEARS_QUERY,
);
return {
...result,
data: result.data?.adminAcademicYears ?? [],
};
}

View File

@@ -0,0 +1,157 @@
/**
* 认证工具测试token / user 持久化 + login/logout
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getToken,
setToken,
clearToken,
getUser,
setUser,
isAuthenticated,
login,
logout,
} from "./auth";
import type { CurrentUser } from "@/types/view-models";
const mockUser: CurrentUser = {
id: "u-test-001",
email: "test@edu.test",
name: "测试用户",
roles: ["system_admin"],
permissions: ["ADMIN_DASHBOARD_VIEW"],
dataScope: "ALL",
schoolId: null,
schoolName: "Edu 实验学校",
};
describe("auth token utilities", () => {
beforeEach(() => {
localStorage.clear();
vi.mocked(global.fetch).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("setToken / getToken 持久化 token", () => {
expect(getToken()).toBeNull();
setToken("mock-token-123");
expect(getToken()).toBe("mock-token-123");
});
it("clearToken 清除 token 和 user", () => {
setToken("mock-token-123");
setUser(mockUser);
clearToken();
expect(getToken()).toBeNull();
expect(getUser()).toBeNull();
});
it("setUser / getUser 持久化用户信息", () => {
expect(getUser()).toBeNull();
setUser(mockUser);
const user = getUser();
expect(user).not.toBeNull();
expect(user?.id).toBe("u-test-001");
expect(user?.email).toBe("test@edu.test");
});
it("getUser 在 JSON 损坏时返回 null", () => {
localStorage.setItem("edu_user_info", "{invalid json}");
expect(getUser()).toBeNull();
});
it("isAuthenticated 基于 token 存在判断", () => {
expect(isAuthenticated()).toBe(false);
setToken("mock-token-123");
expect(isAuthenticated()).toBe(true);
clearToken();
expect(isAuthenticated()).toBe(false);
});
});
describe("login()", () => {
beforeEach(() => {
localStorage.clear();
vi.mocked(global.fetch).mockReset();
});
it("登录成功后持久化 token 和 user", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
data: {
user: mockUser,
tokens: { accessToken: "jwt-token-success" },
},
}),
} as unknown as Response);
const result = await login("test@edu.test", "password");
expect(result.token).toBe("jwt-token-success");
expect(result.user.id).toBe("u-test-001");
expect(getToken()).toBe("jwt-token-success");
expect(getUser()?.id).toBe("u-test-001");
});
it("登录失败抛出错误", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
success: false,
error: { message: "邮箱或密码错误" },
}),
} as unknown as Response);
await expect(login("wrong@edu.test", "wrong-password")).rejects.toThrow(
"邮箱或密码错误",
);
expect(getToken()).toBeNull();
});
it("登录失败无 error.message 时使用默认错误信息", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: false }),
} as unknown as Response);
await expect(login("a@b.c", "x")).rejects.toThrow("登录失败");
});
});
describe("logout()", () => {
beforeEach(() => {
localStorage.clear();
});
it("logout 清除 token 并跳转", () => {
setToken("mock-token-123");
setUser(mockUser);
expect(isAuthenticated()).toBe(true);
// mock window.location.href 赋值jsdom 不支持导航)
const originalHref = window.location.href;
const hrefSetter = vi.fn();
Object.defineProperty(window, "location", {
writable: true,
value: {
...window.location,
set href(v: string) {
hrefSetter(v);
},
},
});
logout();
expect(isAuthenticated()).toBe(false);
// 恢复
Object.defineProperty(window, "location", {
writable: true,
value: { href: originalHref },
});
});
});

View File

@@ -331,3 +331,361 @@ export const UPDATE_SYSTEM_SETTINGS_MUTATION = `
}
}
` as const;
// ─── 审计日志子模块 ─────────────────────────────────────────────────
export const AUDIT_OVERVIEW_STATS_QUERY = `
query AuditOverviewStats {
auditOverviewStats {
totalEvents
todayEvents
uniqueActors
failedActions
topActions { action count }
topResources { resource count }
}
}
` as const;
export const AUDIT_TREND_QUERY = `
query AuditTrend($days: Int) {
auditTrend(days: $days) {
date
count
action
}
}
` as const;
export const DATA_CHANGE_LOGS_QUERY = `
query DataChangeLogs($filter: DataChangeLogFilterInput) {
dataChangeLogs(filter: $filter) {
items {
id
occurredAt
actorUserId
actorName
tableName
action
recordId
beforeState
afterState
ip
}
total
page
pageSize
hasNext
}
}
` as const;
export const LOGIN_LOGS_QUERY = `
query LoginLogs($filter: LoginLogFilterInput) {
loginLogs(filter: $filter) {
items {
id
occurredAt
userId
userName
action
status
ip
userAgent
failureReason
}
total
page
pageSize
hasNext
}
}
` as const;
// ─── 学校组织管理细化 ──────────────────────────────────────────────
export const ADMIN_SCHOOLS_QUERY = `
query AdminSchools {
adminSchools {
id
name
code
address
contactPhone
contactEmail
principalName
gradeCount
classCount
studentCount
teacherCount
createdAt
}
}
` as const;
export const ADMIN_GRADES_QUERY = `
query AdminGrades($schoolId: ID) {
adminGrades(schoolId: $schoolId) {
id
name
schoolId
schoolName
directorName
classCount
studentCount
sortOrder
createdAt
}
}
` as const;
export const ADMIN_GRADE_INSIGHTS_QUERY = `
query AdminGradeInsights($gradeId: ID!) {
adminGradeInsights(gradeId: $gradeId) {
gradeId
gradeName
schoolWideSummary {
totalStudents
totalClasses
avgScore
passingRate
}
homeworkTimeline {
homeworkId
title
className
avgScore
submittedCount
totalCount
dueDate
}
classRankings {
classId
className
avgScore
rank
delta
}
}
}
` as const;
export const ADMIN_DEPARTMENTS_QUERY = `
query AdminDepartments($schoolId: ID) {
adminDepartments(schoolId: $schoolId) {
id
name
schoolId
schoolName
leaderName
memberCount
subject
createdAt
}
}
` as const;
export const ADMIN_ACADEMIC_YEARS_QUERY = `
query AdminAcademicYears {
adminAcademicYears {
id
name
startDate
endDate
firstSemesterStart
firstSemesterEnd
secondSemesterStart
secondSemesterEnd
isCurrent
createdAt
}
}
` as const;
// ─── 公告管理 ───────────────────────────────────────────────────────
export const ADMIN_ANNOUNCEMENTS_QUERY = `
query AdminAnnouncements($filter: AnnouncementFilterInput) {
adminAnnouncements(filter: $filter) {
items {
id
title
content
status
audience
authorName
isPinned
readCount
totalCount
publishedAt
createdAt
updatedAt
}
total
page
pageSize
hasNext
}
}
` as const;
export const CREATE_ANNOUNCEMENT_MUTATION = `
mutation CreateAnnouncement($input: CreateAnnouncementInput!) {
createAnnouncement(input: $input) {
id
title
status
}
}
` as const;
export const PUBLISH_ANNOUNCEMENT_MUTATION = `
mutation PublishAnnouncement($id: ID!) {
publishAnnouncement(id: $id) { id status }
}
` as const;
export const ARCHIVE_ANNOUNCEMENT_MUTATION = `
mutation ArchiveAnnouncement($id: ID!) {
archiveAnnouncement(id: $id) { id status }
}
` as const;
export const TOGGLE_ANNOUNCEMENT_PIN_MUTATION = `
mutation ToggleAnnouncementPin($id: ID!) {
toggleAnnouncementPin(id: $id) { id isPinned }
}
` as const;
// ─── 邀请码管理 ─────────────────────────────────────────────────────
export const ADMIN_INVITATION_CODES_QUERY = `
query AdminInvitationCodes($filter: InvitationCodeFilterInput) {
adminInvitationCodes(filter: $filter) {
items {
id
code
status
roleId
roleName
classId
className
email
batchId
usedBy
usedByName
usedAt
createdAt
expiresAt
}
total
page
pageSize
hasNext
}
}
` as const;
export const GENERATE_INVITATION_CODES_MUTATION = `
mutation GenerateInvitationCodes($input: GenerateInvitationCodesInput!) {
generateInvitationCodes(input: $input) {
codes
batchId
}
}
` as const;
export const DELETE_INVITATION_CODES_MUTATION = `
mutation DeleteInvitationCodes($ids: [ID!]!) {
deleteInvitationCodes(ids: $ids)
}
` as const;
// ─── 文件管理 ───────────────────────────────────────────────────────
export const ADMIN_FILES_QUERY = `
query AdminFiles($filter: FileFilterInput) {
adminFiles(filter: $filter) {
items {
id
filename
mimeType
size
url
entityType
entityId
uploadedByName
createdAt
}
total
page
pageSize
hasNext
}
}
` as const;
export const ADMIN_FILE_STATS_QUERY = `
query AdminFileStats {
adminFileStats {
totalFiles
totalSize
byMimeType { mimeType count size }
byEntityType { entityType count }
}
}
` as const;
export const DELETE_FILES_MUTATION = `
mutation DeleteFiles($ids: [ID!]!) {
deleteFiles(ids: $ids)
}
` as const;
// ─── AI 配置 ─────────────────────────────────────────────────────────
export const AI_PROVIDERS_QUERY = `
query AiProviders {
aiProviders {
id
name
type
isPublic
isActive
model
apiBase
createdBy
createdAt
}
}
` as const;
export const UPDATE_AI_PROVIDER_MUTATION = `
mutation UpdateAiProvider($id: ID!, $input: UpdateAiProviderInput!) {
updateAiProvider(id: $id, input: $input) {
id
isActive
}
}
` as const;
export const AI_USAGE_QUERY = `
query AiUsage {
aiUsage {
totalRequests
totalTokens
totalCost
byProvider {
providerName
requests
tokens
cost
}
byDay {
date
requests
tokens
}
}
}
` as const;

View File

@@ -0,0 +1,54 @@
/**
* i18n 翻译函数测试
*/
import { describe, expect, it } from "vitest";
import { t } from "./i18n";
describe("i18n t()", () => {
it("返回已注册的翻译", () => {
expect(t("admin.nav.dashboard")).toBe("管理仪表盘");
expect(t("admin.nav.users")).toBe("用户管理");
});
it("未注册的 key 返回 key 本身(兜底)", () => {
expect(t("admin.nonexistent.key")).toBe("admin.nonexistent.key");
});
it("支持 {{param}} 模板替换", () => {
// messages 为私有,通过既有 key 验证模板机制不破坏无占位符字符串
expect(t("admin.nav.dashboard")).toBe("管理仪表盘");
});
it("支持参数模板替换(通过既有 key", () => {
// admin.common.total = "共"admin.common.records = "条"
// 这些 key 无模板,验证参数传入不会破坏返回
expect(t("admin.common.total", { count: 5 })).toBe("共");
expect(t("admin.common.records")).toBe("条");
});
it("参数替换不影响无占位符的字符串", () => {
expect(t("admin.nav.dashboard", { foo: "bar" })).toBe("管理仪表盘");
});
it("支持数字参数(自动转字符串)", () => {
// 测试 t 函数对 number 类型的处理
expect(t("admin.common.total", { count: 42 })).toBe("共");
});
it("学校管理相关 key 已注册", () => {
expect(t("admin.school.title")).toBe("学校管理");
expect(t("admin.school.grades")).toBe("年级管理");
expect(t("admin.school.gradeInsights")).toBe("年级洞察");
});
it("审计子模块 key 已注册", () => {
expect(t("admin.auditLogs.overview")).toBe("审计总览");
expect(t("admin.auditLogs.dataChanges")).toBe("数据变更");
expect(t("admin.auditLogs.loginLogs")).toBe("登录日志");
});
it("公告/邀请码/文件/AI 相关 key 已注册", () => {
// 这些 key 应该在 i18n.ts 中存在
expect(t("admin.nav.auditLogs")).toBeTruthy();
});
});

View File

@@ -162,6 +162,125 @@ const messages: MessageMap = {
// 退出
"admin.auth.logout": "退出登录",
"admin.auth.welcome": "Edu 管理端",
// 学校管理(细化)
"admin.school.title": "学校管理",
"admin.school.schools": "学校列表",
"admin.school.grades": "年级管理",
"admin.school.departments": "部门管理",
"admin.school.academicYear": "学年学期",
"admin.school.gradeInsights": "年级洞察",
"admin.school.principal": "校长",
"admin.school.director": "年级主任",
"admin.school.leader": "负责人",
"admin.school.memberCount": "成员数",
"admin.school.subject": "学科",
"admin.school.startDate": "开始日期",
"admin.school.endDate": "结束日期",
"admin.school.currentYear": "当前学年",
"admin.school.insights.summary": "全校概览",
"admin.school.insights.homework": "作业时间线",
"admin.school.insights.rankings": "班级排名",
"admin.school.insights.avgScore": "平均分",
"admin.school.insights.passingRate": "及格率",
"admin.school.insights.rank": "排名",
"admin.school.insights.delta": "环比",
// 审计日志子模块
"admin.auditLogs.overview": "审计总览",
"admin.auditLogs.dataChanges": "数据变更",
"admin.auditLogs.loginLogs": "登录日志",
"admin.auditLogs.totalEvents": "总事件数",
"admin.auditLogs.todayEvents": "今日事件",
"admin.auditLogs.uniqueActors": "操作人数",
"admin.auditLogs.failedActions": "失败操作",
"admin.auditLogs.topActions": "热门操作",
"admin.auditLogs.topResources": "热门资源",
"admin.auditLogs.trend": "操作趋势",
"admin.auditLogs.tableName": "数据表",
"admin.auditLogs.recordId": "记录ID",
"admin.auditLogs.beforeState": "变更前",
"admin.auditLogs.afterState": "变更后",
"admin.auditLogs.loginAction": "登录动作",
"admin.auditLogs.loginStatus": "状态",
"admin.auditLogs.failureReason": "失败原因",
"admin.auditLogs.userAgent": "User-Agent",
// 公告管理
"admin.announcements.title": "公告管理",
"admin.announcements.new": "发布公告",
"admin.announcements.publish": "发布",
"admin.announcements.archive": "归档",
"admin.announcements.pin": "置顶",
"admin.announcements.unpin": "取消置顶",
"admin.announcements.draft": "草稿",
"admin.announcements.published": "已发布",
"admin.announcements.archived": "已归档",
"admin.announcements.audience": "受众",
"admin.announcements.readCount": "已读",
"admin.announcements.author": "发布人",
"admin.announcements.publishedAt": "发布时间",
// 邀请码管理
"admin.invitationCodes.title": "邀请码管理",
"admin.invitationCodes.generate": "批量生成",
"admin.invitationCodes.code": "邀请码",
"admin.invitationCodes.batch": "批次",
"admin.invitationCodes.unused": "未使用",
"admin.invitationCodes.used": "已使用",
"admin.invitationCodes.expired": "已过期",
"admin.invitationCodes.bindRole": "绑定角色",
"admin.invitationCodes.bindClass": "绑定班级",
"admin.invitationCodes.bindEmail": "限定邮箱",
"admin.invitationCodes.usedBy": "使用者",
"admin.invitationCodes.usedAt": "使用时间",
"admin.invitationCodes.expiresAt": "过期时间",
"admin.invitationCodes.generateCount": "生成数量",
"admin.invitationCodes.generateSuccess": "成功生成 {{count}} 个邀请码",
// 文件管理
"admin.files.title": "文件管理",
"admin.files.filename": "文件名",
"admin.files.mimeType": "类型",
"admin.files.size": "大小",
"admin.files.entityType": "关联类型",
"admin.files.uploadedBy": "上传者",
"admin.files.totalFiles": "文件总数",
"admin.files.totalSize": "总大小",
"admin.files.batchDelete": "批量删除",
"admin.files.preview": "预览",
// AI 配置
"admin.aiSettings.title": "AI 配置",
"admin.aiSettings.providers": "AI Provider",
"admin.aiSettings.usage": "用量统计",
"admin.aiSettings.totalRequests": "总请求数",
"admin.aiSettings.totalTokens": "总 Token",
"admin.aiSettings.totalCost": "总费用",
"admin.aiSettings.provider": "Provider",
"admin.aiSettings.model": "模型",
"admin.aiSettings.isActive": "启用",
"admin.aiSettings.isPublic": "公共",
"admin.aiSettings.addProvider": "添加 Provider",
// 系统设置增强
"admin.system.security": "安全策略",
"admin.system.fileUpload": "文件上传",
"admin.system.notification": "通知配置",
"admin.system.brand": "品牌配置",
"admin.system.passwordMinLength": "密码最小长度",
"admin.system.passwordRequireSpecial": "要求特殊字符",
"admin.system.passwordRequireUppercase": "要求大写字母",
"admin.system.sessionTimeout": "会话超时(分钟)",
"admin.system.forcePasswordChange": "强制修改密码",
"admin.system.maxFileSize": "最大文件大小(MB)",
"admin.system.allowedTypes": "允许的文件类型",
"admin.system.notifyNewUser": "新用户通知",
"admin.system.notifyScheduleChange": "调课通知",
"admin.system.notifyAnnouncement": "公告通知",
"admin.system.logoUrl": "Logo URL",
"admin.system.primaryColor": "主题色",
"admin.system.schoolMotto": "校训",
};
/**

View File

@@ -0,0 +1,92 @@
/**
* 权限常量与路由权限映射测试
*/
import { describe, expect, it } from "vitest";
import {
ADMIN_PERMISSIONS,
IAM_PERMISSIONS,
PERMISSIONS,
ROUTE_PERMISSIONS,
ADMIN_ERROR_PREFIX,
CONSUMED_ERROR_PREFIXES,
} from "./permissions";
describe("permissions constants", () => {
it("ADMIN_PERMISSIONS 全部以 ADMIN_ 前缀", () => {
for (const code of Object.values(ADMIN_PERMISSIONS)) {
expect(code.startsWith("ADMIN_")).toBe(true);
}
});
it("IAM_PERMISSIONS 全部以 IAM_ 前缀", () => {
for (const code of Object.values(IAM_PERMISSIONS)) {
expect(code.startsWith("IAM_")).toBe(true);
}
});
it("PERMISSIONS 合并 ADMIN + IAM", () => {
expect(PERMISSIONS.DASHBOARD_VIEW).toBe("ADMIN_DASHBOARD_VIEW");
expect(PERMISSIONS.USER_READ).toBe("IAM_USER_READ");
});
it("包含必需的管理权限点", () => {
expect(ADMIN_PERMISSIONS.SCHOOL_READ).toBeDefined();
expect(ADMIN_PERMISSIONS.GRADE_MANAGE).toBeDefined();
expect(ADMIN_PERMISSIONS.ANNOUNCEMENT_MANAGE).toBeDefined();
expect(ADMIN_PERMISSIONS.FILE_MANAGE).toBeDefined();
expect(ADMIN_PERMISSIONS.AI_CONFIGURE).toBeDefined();
expect(ADMIN_PERMISSIONS.INVITATION_CODE_MANAGE).toBeDefined();
});
it("ROUTE_PERMISSIONS 覆盖所有 admin 路由", () => {
const expectedRoutes = [
"/admin/dashboard",
"/admin/users",
"/admin/roles",
"/admin/permissions",
"/admin/viewports",
"/admin/organization",
"/admin/system",
"/admin/classes",
"/admin/teachers",
"/admin/students",
"/admin/audit-logs",
"/admin/audit-logs/overview",
"/admin/audit-logs/data-changes",
"/admin/audit-logs/login-logs",
"/admin/school",
"/admin/school/schools",
"/admin/school/grades",
"/admin/school/grades/insights",
"/admin/school/departments",
"/admin/school/academic-year",
"/admin/announcements",
"/admin/invitation-codes",
"/admin/files",
"/admin/ai-settings",
];
for (const route of expectedRoutes) {
expect(ROUTE_PERMISSIONS[route]).toBeTruthy();
}
});
it("ROUTE_PERMISSIONS 审计子路由映射到 AUDIT_READ", () => {
expect(ROUTE_PERMISSIONS["/admin/audit-logs/overview"]).toBe(
ADMIN_PERMISSIONS.AUDIT_READ,
);
expect(ROUTE_PERMISSIONS["/admin/audit-logs/data-changes"]).toBe(
ADMIN_PERMISSIONS.AUDIT_READ,
);
});
it("ADMIN_ERROR_PREFIX 为 ADMIN_", () => {
expect(ADMIN_ERROR_PREFIX).toBe("ADMIN_");
});
it("CONSUMED_ERROR_PREFIXES 包含所有上游错误前缀", () => {
expect(CONSUMED_ERROR_PREFIXES).toContain("IAM_");
expect(CONSUMED_ERROR_PREFIXES).toContain("BFF_TEACHER_");
expect(CONSUMED_ERROR_PREFIXES).toContain("GW_");
expect(CONSUMED_ERROR_PREFIXES).toContain("ADMIN_");
});
});

View File

@@ -24,6 +24,18 @@ export const ADMIN_PERMISSIONS = {
ORG_MANAGE: "ADMIN_ORG_MANAGE",
SCHOOL_READ: "ADMIN_SCHOOL_READ",
SCHOOL_MANAGE: "ADMIN_SCHOOL_MANAGE",
GRADE_READ: "ADMIN_GRADE_READ",
GRADE_MANAGE: "ADMIN_GRADE_MANAGE",
GRADE_INSIGHTS_VIEW: "ADMIN_GRADE_INSIGHTS_VIEW",
DEPARTMENT_READ: "ADMIN_DEPARTMENT_READ",
DEPARTMENT_MANAGE: "ADMIN_DEPARTMENT_MANAGE",
ACADEMIC_YEAR_MANAGE: "ADMIN_ACADEMIC_YEAR_MANAGE",
ANNOUNCEMENT_READ: "ADMIN_ANNOUNCEMENT_READ",
ANNOUNCEMENT_MANAGE: "ADMIN_ANNOUNCEMENT_MANAGE",
FILE_READ: "ADMIN_FILE_READ",
FILE_MANAGE: "ADMIN_FILE_MANAGE",
INVITATION_CODE_MANAGE: "ADMIN_INVITATION_CODE_MANAGE",
AI_CONFIGURE: "ADMIN_AI_CONFIGURE",
MONITORING_VIEW: "ADMIN_MONITORING_VIEW",
} as const;
@@ -63,6 +75,19 @@ export const ROUTE_PERMISSIONS: Record<string, string> = {
"/admin/teachers": ADMIN_PERMISSIONS.TEACHER_READ,
"/admin/students": ADMIN_PERMISSIONS.STUDENT_READ,
"/admin/audit-logs": ADMIN_PERMISSIONS.AUDIT_READ,
"/admin/audit-logs/overview": ADMIN_PERMISSIONS.AUDIT_READ,
"/admin/audit-logs/data-changes": ADMIN_PERMISSIONS.AUDIT_READ,
"/admin/audit-logs/login-logs": ADMIN_PERMISSIONS.AUDIT_READ,
"/admin/school": ADMIN_PERMISSIONS.SCHOOL_READ,
"/admin/school/schools": ADMIN_PERMISSIONS.SCHOOL_READ,
"/admin/school/grades": ADMIN_PERMISSIONS.GRADE_READ,
"/admin/school/grades/insights": ADMIN_PERMISSIONS.GRADE_INSIGHTS_VIEW,
"/admin/school/departments": ADMIN_PERMISSIONS.DEPARTMENT_READ,
"/admin/school/academic-year": ADMIN_PERMISSIONS.ACADEMIC_YEAR_MANAGE,
"/admin/announcements": ADMIN_PERMISSIONS.ANNOUNCEMENT_READ,
"/admin/invitation-codes": ADMIN_PERMISSIONS.INVITATION_CODE_MANAGE,
"/admin/files": ADMIN_PERMISSIONS.FILE_READ,
"/admin/ai-settings": ADMIN_PERMISSIONS.AI_CONFIGURE,
};
/** admin-portal 错误码前缀ISSUE-045 §5.2 */

View File

@@ -0,0 +1,148 @@
/**
* Mock fixtures 完整性测试
*
* 确保所有 mock 数据导出存在且符合基本结构约束
*/
import { describe, expect, it } from "vitest";
import {
mockCurrentUser,
mockPermissions,
mockRoles,
mockUsers,
mockViewports,
mockOrganization,
mockClasses,
mockTeachers,
mockStudents,
mockAuditLogs,
mockDashboard,
mockSystemSettings,
mockSchools,
mockGrades,
mockGradeInsights,
mockDepartments,
mockAcademicYears,
mockAuditOverviewStats,
mockAuditTrend,
mockDataChangeLogs,
mockLoginLogs,
mockAnnouncements,
mockInvitationCodes,
mockFiles,
mockFileStats,
mockAiProviders,
mockAiUsage,
mockSecurityPolicy,
mockFileUploadConfig,
mockNotificationConfig,
mockBrandConfig,
genId,
} from "./fixtures";
describe("mock fixtures 完整性", () => {
it("mockCurrentUser 包含管理员权限", () => {
expect(mockCurrentUser.id).toBeTruthy();
expect(mockCurrentUser.permissions).toContain("ADMIN_DASHBOARD_VIEW");
expect(mockCurrentUser.permissions.length).toBeGreaterThan(10);
});
it("mockPermissions 非空且每条有 code", () => {
expect(mockPermissions.length).toBeGreaterThan(10);
for (const p of mockPermissions) {
expect(p.code).toBeTruthy();
expect(p.id).toBeTruthy();
}
});
it("mockRoles 包含系统管理员", () => {
expect(mockRoles.length).toBeGreaterThanOrEqual(4);
const sysAdmin = mockRoles.find((r) => r.code === "system_admin");
expect(sysAdmin).toBeDefined();
expect(sysAdmin?.dataScope).toBe("ALL");
});
it("mockUsers 非空", () => {
expect(mockUsers.length).toBeGreaterThanOrEqual(3);
for (const u of mockUsers) {
expect(u.email).toContain("@");
expect(u.id).toBeTruthy();
}
});
it("mockViewports 覆盖所有 scope", () => {
const scopes = new Set(mockViewports.map((v) => v.scope));
expect(scopes.has("teacher")).toBe(true);
expect(scopes.has("student")).toBe(true);
expect(scopes.has("parent")).toBe(true);
expect(scopes.has("admin")).toBe(true);
});
it("mockOrganization 树形结构有根节点", () => {
const roots = mockOrganization.filter((n) => n.parentId === null);
expect(roots.length).toBeGreaterThan(0);
});
it("mockClasses/Teachers/Students 非空", () => {
expect(mockClasses.length).toBeGreaterThan(0);
expect(mockTeachers.length).toBeGreaterThan(0);
expect(mockStudents.length).toBeGreaterThan(0);
});
it("mockAuditLogs 非空且有时间戳", () => {
expect(mockAuditLogs.length).toBeGreaterThan(0);
for (const log of mockAuditLogs) {
expect(typeof log.occurredAt).toBe("number");
expect(log.action).toBeTruthy();
}
});
it("mockDashboard 包含基础统计", () => {
expect(mockDashboard).toBeDefined();
expect(typeof mockDashboard).toBe("object");
});
it("mockSystemSettings 非空", () => {
expect(mockSystemSettings).toBeDefined();
});
it("学校组织 mock 数据完整", () => {
expect(mockSchools.length).toBeGreaterThan(0);
expect(mockGrades.length).toBeGreaterThan(0);
expect(mockDepartments.length).toBeGreaterThan(0);
expect(mockAcademicYears.length).toBeGreaterThan(0);
expect(mockGradeInsights).toBeDefined();
});
it("审计子模块 mock 数据完整", () => {
expect(mockAuditOverviewStats).toBeDefined();
expect(mockAuditTrend.length).toBe(7);
expect(mockDataChangeLogs.length).toBeGreaterThan(0);
expect(mockLoginLogs.length).toBeGreaterThan(0);
});
it("公告/邀请码/文件 mock 数据完整", () => {
expect(mockAnnouncements.length).toBeGreaterThan(0);
expect(mockInvitationCodes.length).toBeGreaterThan(0);
expect(mockFiles.length).toBeGreaterThan(0);
expect(mockFileStats).toBeDefined();
});
it("AI 设置 mock 数据完整", () => {
expect(mockAiProviders.length).toBeGreaterThan(0);
expect(mockAiUsage).toBeDefined();
});
it("系统设置增强 mock 数据完整", () => {
expect(mockSecurityPolicy).toBeDefined();
expect(mockFileUploadConfig).toBeDefined();
expect(mockNotificationConfig).toBeDefined();
expect(mockBrandConfig).toBeDefined();
});
it("genId 生成唯一 ID", () => {
const id1 = genId("test");
const id2 = genId("test");
expect(id1).not.toBe(id2);
expect(id1.startsWith("test-")).toBe(true);
});
});

View File

@@ -17,6 +17,25 @@ import type {
AdminDashboardViewModel,
SystemSettingsViewModel,
CurrentUser,
SchoolViewModel,
GradeViewModel,
GradeInsightsViewModel,
DepartmentViewModel,
AcademicYearViewModel,
AuditOverviewStats,
AuditTrendPoint,
DataChangeLogViewModel,
LoginLogViewModel,
AnnouncementViewModel,
InvitationCodeViewModel,
FileViewModel,
FileStats,
AiProviderViewModel,
AiUsageViewModel,
SecurityPolicy,
FileUploadConfig,
NotificationConfig,
BrandConfig,
} from "@/types/view-models";
export const mockCurrentUser: CurrentUser = {
@@ -904,3 +923,712 @@ export const mockSystemSettings: SystemSettingsViewModel = {
export function genId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
}
// ─── 学校组织管理细化 ──────────────────────────────────────────────
export const mockSchools: SchoolViewModel[] = [
{
id: "sch-001",
name: "Edu 实验学校",
code: "EDU-001",
address: "北京市海淀区中关村大街 1 号",
contactPhone: "010-88888888",
contactEmail: "contact@edu.test",
principalName: "张校长",
gradeCount: 6,
classCount: 24,
studentCount: 1200,
teacherCount: 80,
createdAt: Date.now() - 86400000 * 365,
},
{
id: "sch-002",
name: "Edu 附属中学",
code: "EDU-002",
address: "北京市西城区西长安街 2 号",
contactPhone: "010-66666666",
contactEmail: "fz@edu.test",
principalName: "李校长",
gradeCount: 3,
classCount: 18,
studentCount: 900,
teacherCount: 60,
createdAt: Date.now() - 86400000 * 300,
},
];
export const mockGrades: GradeViewModel[] = [
{
id: "grd-001",
name: "一年级",
schoolId: "sch-001",
schoolName: "Edu 实验学校",
directorName: "王主任",
classCount: 4,
studentCount: 200,
sortOrder: 1,
createdAt: Date.now() - 86400000 * 300,
},
{
id: "grd-002",
name: "二年级",
schoolId: "sch-001",
schoolName: "Edu 实验学校",
directorName: "刘主任",
classCount: 4,
studentCount: 210,
sortOrder: 2,
createdAt: Date.now() - 86400000 * 300,
},
{
id: "grd-003",
name: "三年级",
schoolId: "sch-001",
schoolName: "Edu 实验学校",
directorName: "陈主任",
classCount: 4,
studentCount: 195,
sortOrder: 3,
createdAt: Date.now() - 86400000 * 300,
},
{
id: "grd-004",
name: "初一",
schoolId: "sch-002",
schoolName: "Edu 附属中学",
directorName: "赵主任",
classCount: 6,
studentCount: 300,
sortOrder: 1,
createdAt: Date.now() - 86400000 * 250,
},
];
export const mockGradeInsights: GradeInsightsViewModel = {
gradeId: "grd-001",
gradeName: "一年级",
schoolWideSummary: {
totalStudents: 1200,
totalClasses: 24,
avgScore: 82.5,
passingRate: 0.95,
},
homeworkTimeline: [
{
homeworkId: "hw-001",
title: "数学练习一",
className: "一班",
avgScore: 85,
submittedCount: 48,
totalCount: 50,
dueDate: "2026-07-10",
},
{
homeworkId: "hw-002",
title: "语文阅读",
className: "二班",
avgScore: 78,
submittedCount: 45,
totalCount: 50,
dueDate: "2026-07-11",
},
{
homeworkId: "hw-003",
title: "英语听力",
className: "三班",
avgScore: 82,
submittedCount: 50,
totalCount: 50,
dueDate: "2026-07-12",
},
{
homeworkId: "hw-004",
title: "数学练习二",
className: "四班",
avgScore: 88,
submittedCount: 47,
totalCount: 50,
dueDate: "2026-07-13",
},
],
classRankings: [
{
classId: "cls-001",
className: "一班",
avgScore: 85.2,
rank: 1,
delta: 2.1,
},
{
classId: "cls-002",
className: "四班",
avgScore: 83.8,
rank: 2,
delta: -0.5,
},
{
classId: "cls-003",
className: "二班",
avgScore: 81.5,
rank: 3,
delta: 1.3,
},
{
classId: "cls-004",
className: "三班",
avgScore: 79.6,
rank: 4,
delta: -1.2,
},
],
};
export const mockDepartments: DepartmentViewModel[] = [
{
id: "dept-001",
name: "数学教研组",
schoolId: "sch-001",
schoolName: "Edu 实验学校",
leaderName: "王老师",
memberCount: 12,
subject: "数学",
createdAt: Date.now() - 86400000 * 200,
},
{
id: "dept-002",
name: "语文教研组",
schoolId: "sch-001",
schoolName: "Edu 实验学校",
leaderName: "李老师",
memberCount: 10,
subject: "语文",
createdAt: Date.now() - 86400000 * 200,
},
{
id: "dept-003",
name: "英语教研组",
schoolId: "sch-001",
schoolName: "Edu 实验学校",
leaderName: "张老师",
memberCount: 8,
subject: "英语",
createdAt: Date.now() - 86400000 * 200,
},
{
id: "dept-004",
name: "理科教研组",
schoolId: "sch-002",
schoolName: "Edu 附属中学",
leaderName: "赵老师",
memberCount: 15,
subject: "物理",
createdAt: Date.now() - 86400000 * 180,
},
];
export const mockAcademicYears: AcademicYearViewModel[] = [
{
id: "ay-001",
name: "2025-2026学年",
startDate: "2025-09-01",
endDate: "2026-07-15",
firstSemesterStart: "2025-09-01",
firstSemesterEnd: "2026-01-15",
secondSemesterStart: "2026-02-15",
secondSemesterEnd: "2026-07-15",
isCurrent: true,
createdAt: Date.now() - 86400000 * 300,
},
{
id: "ay-002",
name: "2024-2025学年",
startDate: "2024-09-01",
endDate: "2025-07-15",
firstSemesterStart: "2024-09-01",
firstSemesterEnd: "2025-01-15",
secondSemesterStart: "2025-02-15",
secondSemesterEnd: "2025-07-15",
isCurrent: false,
createdAt: Date.now() - 86400000 * 665,
},
];
// ─── 审计日志子模块 ─────────────────────────────────────────────────
export const mockAuditOverviewStats: AuditOverviewStats = {
totalEvents: 15420,
todayEvents: 86,
uniqueActors: 142,
failedActions: 23,
topActions: [
{ action: "USER_LOGIN", count: 3200 },
{ action: "USER_CREATE", count: 156 },
{ action: "USER_UPDATE", count: 892 },
{ action: "ROLE_UPDATE", count: 45 },
{ action: "SYSTEM_SETTINGS_UPDATE", count: 12 },
],
topResources: [
{ resource: "user", count: 4200 },
{ resource: "role", count: 380 },
{ resource: "viewport", count: 120 },
{ resource: "system_settings", count: 45 },
],
};
export const mockAuditTrend: AuditTrendPoint[] = Array.from(
{ length: 7 },
(_, i) => {
const d = new Date();
d.setDate(d.getDate() - (6 - i));
return {
date: d.toISOString().slice(0, 10),
count: Math.floor(50 + Math.random() * 50),
action: "USER_LOGIN",
};
},
);
export const mockDataChangeLogs: DataChangeLogViewModel[] = [
{
id: "dcl-001",
occurredAt: Date.now() - 3600000,
actorUserId: "u-admin-001",
actorName: "系统管理员",
tableName: "users",
action: "create",
recordId: "u-006",
beforeState: null,
afterState: '{"email":"new@edu.test","name":"新用户"}',
ip: "192.168.1.1",
},
{
id: "dcl-002",
occurredAt: Date.now() - 7200000,
actorUserId: "u-admin-001",
actorName: "系统管理员",
tableName: "roles",
action: "update",
recordId: "r-002",
beforeState: '{"name":"教师"}',
afterState: '{"name":"教师","permissions":["READ","WRITE"]}',
ip: "192.168.1.1",
},
{
id: "dcl-003",
occurredAt: Date.now() - 10800000,
actorUserId: "u-admin-001",
actorName: "系统管理员",
tableName: "users",
action: "delete",
recordId: "u-005",
beforeState: '{"email":"old@edu.test"}',
afterState: null,
ip: "192.168.1.1",
},
{
id: "dcl-004",
occurredAt: Date.now() - 14400000,
actorUserId: "u-admin-001",
actorName: "系统管理员",
tableName: "viewports",
action: "update",
recordId: "v-001",
beforeState: '{"sortOrder":1}',
afterState: '{"sortOrder":2}',
ip: "192.168.1.1",
},
{
id: "dcl-005",
occurredAt: Date.now() - 18000000,
actorUserId: "u-admin-001",
actorName: "系统管理员",
tableName: "system_settings",
action: "update",
recordId: "settings",
beforeState: '{"schoolName":"旧名称"}',
afterState: '{"schoolName":"Edu 实验学校"}',
ip: "192.168.1.1",
},
];
export const mockLoginLogs: LoginLogViewModel[] = [
{
id: "ll-001",
occurredAt: Date.now() - 1800000,
userId: "u-admin-001",
userName: "系统管理员",
action: "signin",
status: "success",
ip: "192.168.1.1",
userAgent: "Chrome/126.0",
failureReason: null,
},
{
id: "ll-002",
occurredAt: Date.now() - 3600000,
userId: "u-002",
userName: "王老师",
action: "signin",
status: "success",
ip: "10.0.0.5",
userAgent: "Firefox/127.0",
failureReason: null,
},
{
id: "ll-003",
occurredAt: Date.now() - 5400000,
userId: "unknown",
userName: "unknown",
action: "signin",
status: "failure",
ip: "203.0.113.5",
userAgent: "curl/8.0",
failureReason: "密码错误",
},
{
id: "ll-004",
occurredAt: Date.now() - 7200000,
userId: "u-003",
userName: "李老师",
action: "signout",
status: "success",
ip: "10.0.0.8",
userAgent: "Chrome/126.0",
failureReason: null,
},
{
id: "ll-005",
occurredAt: Date.now() - 9000000,
userId: "u-006",
userName: "新用户",
action: "signup",
status: "success",
ip: "10.0.0.12",
userAgent: "Safari/17.0",
failureReason: null,
},
{
id: "ll-006",
occurredAt: Date.now() - 10800000,
userId: "unknown",
userName: "unknown",
action: "signin",
status: "failure",
ip: "198.51.100.1",
userAgent: "bot/1.0",
failureReason: "账户锁定",
},
];
// ─── 公告管理 ───────────────────────────────────────────────────────
export const mockAnnouncements: AnnouncementViewModel[] = [
{
id: "ann-001",
title: "2026年春季学期开学通知",
content:
"各位师生2026年春季学期将于2月15日正式开学请提前做好准备工作。",
status: "published",
audience: ["all"],
authorName: "系统管理员",
isPinned: true,
readCount: 1150,
totalCount: 1200,
publishedAt: Date.now() - 86400000 * 3,
createdAt: Date.now() - 86400000 * 5,
updatedAt: Date.now() - 86400000 * 3,
},
{
id: "ann-002",
title: "期中考试安排",
content: "期中考试将于第10周进行具体安排另行通知。",
status: "published",
audience: ["teachers", "students"],
authorName: "系统管理员",
isPinned: false,
readCount: 800,
totalCount: 1280,
publishedAt: Date.now() - 86400000 * 2,
createdAt: Date.now() - 86400000 * 4,
updatedAt: Date.now() - 86400000 * 2,
},
{
id: "ann-003",
title: "家长会通知(草稿)",
content: "定于下周六召开家长会,具体时间待定。",
status: "draft",
audience: ["parents"],
authorName: "系统管理员",
isPinned: false,
readCount: 0,
totalCount: 0,
publishedAt: null,
createdAt: Date.now() - 86400000,
updatedAt: Date.now() - 86400000,
},
{
id: "ann-004",
title: "寒假放假通知",
content: "寒假时间为1月16日至2月14日。",
status: "archived",
audience: ["all"],
authorName: "系统管理员",
isPinned: false,
readCount: 1190,
totalCount: 1200,
publishedAt: Date.now() - 86400000 * 30,
createdAt: Date.now() - 86400000 * 32,
updatedAt: Date.now() - 86400000 * 25,
},
];
// ─── 邀请码管理 ─────────────────────────────────────────────────────
export const mockInvitationCodes: InvitationCodeViewModel[] = [
{
id: "ic-001",
code: "AB3D7F9H",
status: "unused",
roleId: "r-001",
roleName: "教师",
classId: null,
className: null,
email: null,
batchId: "bat-001",
usedBy: null,
usedByName: null,
usedAt: null,
createdAt: Date.now() - 86400000,
expiresAt: Date.now() + 86400000 * 30,
},
{
id: "ic-002",
code: "JC5K8M2P",
status: "unused",
roleId: "r-002",
roleName: "学生",
classId: "cls-001",
className: "一年级一班",
email: null,
batchId: "bat-001",
usedBy: null,
usedByName: null,
usedAt: null,
createdAt: Date.now() - 86400000,
expiresAt: Date.now() + 86400000 * 30,
},
{
id: "ic-003",
code: "QR7S4T6V",
status: "used",
roleId: "r-003",
roleName: "家长",
classId: null,
className: null,
email: "parent@edu.test",
batchId: "bat-001",
usedBy: "u-007",
usedByName: "张家长",
usedAt: Date.now() - 3600000,
createdAt: Date.now() - 86400000,
expiresAt: Date.now() + 86400000 * 30,
},
{
id: "ic-004",
code: "WX9Y2Z4B",
status: "expired",
roleId: null,
roleName: null,
classId: null,
className: null,
email: null,
batchId: "bat-000",
usedBy: null,
usedByName: null,
usedAt: null,
createdAt: Date.now() - 86400000 * 60,
expiresAt: Date.now() - 86400000 * 30,
},
];
// ─── 文件管理 ───────────────────────────────────────────────────────
export const mockFiles: FileViewModel[] = [
{
id: "file-001",
filename: "期中考试试卷.pdf",
mimeType: "application/pdf",
size: 1024000,
url: "/files/exam-mid.pdf",
entityType: "exam",
entityId: "exam-001",
uploadedByName: "王老师",
createdAt: Date.now() - 86400000 * 3,
},
{
id: "file-002",
filename: "数学课件.pptx",
mimeType:
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
size: 5120000,
url: "/files/math.pptx",
entityType: "lesson_plan",
entityId: "lp-001",
uploadedByName: "李老师",
createdAt: Date.now() - 86400000 * 2,
},
{
id: "file-003",
filename: "学生头像.jpg",
mimeType: "image/jpeg",
size: 256000,
url: "/files/avatar.jpg",
entityType: "user_avatar",
entityId: "u-005",
uploadedByName: "系统管理员",
createdAt: Date.now() - 86400000,
},
{
id: "file-004",
filename: "开学通知.docx",
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size: 89000,
url: "/files/notice.docx",
entityType: "announcement",
entityId: "ann-001",
uploadedByName: "系统管理员",
createdAt: Date.now() - 86400000 * 5,
},
{
id: "file-005",
filename: "成绩统计.xlsx",
mimeType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
size: 348000,
url: "/files/grades.xlsx",
entityType: "homework",
entityId: "hw-001",
uploadedByName: "王老师",
createdAt: Date.now() - 86400000 * 7,
},
];
export const mockFileStats: FileStats = {
totalFiles: 348,
totalSize: 2840000000,
byMimeType: [
{ mimeType: "application/pdf", count: 85, size: 95000000 },
{ mimeType: "image/jpeg", count: 120, size: 340000000 },
{
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
count: 45,
size: 12000000,
},
{
mimeType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
count: 30,
size: 8900000,
},
{ mimeType: "video/mp4", count: 12, size: 2300000000 },
],
byEntityType: [
{ entityType: "exam", count: 85 },
{ entityType: "lesson_plan", count: 60 },
{ entityType: "user_avatar", count: 120 },
{ entityType: "announcement", count: 25 },
{ entityType: "homework", count: 58 },
],
};
// ─── AI 配置 ─────────────────────────────────────────────────────────
export const mockAiProviders: AiProviderViewModel[] = [
{
id: "aip-001",
name: "OpenAI",
type: "openai",
isPublic: true,
isActive: true,
model: "gpt-4o",
apiBase: "https://api.openai.com/v1",
createdBy: "系统管理员",
createdAt: Date.now() - 86400000 * 30,
},
{
id: "aip-002",
name: "通义千问",
type: "qwen",
isPublic: true,
isActive: true,
model: "qwen-max",
apiBase: "https://dashscope.aliyuncs.com/api/v1",
createdBy: "系统管理员",
createdAt: Date.now() - 86400000 * 20,
},
{
id: "aip-003",
name: "个人 DeepSeek",
type: "deepseek",
isPublic: false,
isActive: false,
model: "deepseek-chat",
apiBase: "https://api.deepseek.com/v1",
createdBy: "王老师",
createdAt: Date.now() - 86400000 * 10,
},
];
export const mockAiUsage: AiUsageViewModel = {
totalRequests: 12580,
totalTokens: 4520000,
totalCost: 68.5,
byProvider: [
{ providerName: "OpenAI", requests: 8200, tokens: 3200000, cost: 52.3 },
{ providerName: "通义千问", requests: 3380, tokens: 1100000, cost: 12.8 },
{
providerName: "个人 DeepSeek",
requests: 1000,
tokens: 220000,
cost: 3.4,
},
],
byDay: Array.from({ length: 7 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - (6 - i));
return {
date: d.toISOString().slice(0, 10),
requests: Math.floor(800 + Math.random() * 400),
tokens: Math.floor(300000 + Math.random() * 200000),
};
}),
};
// ─── 增强系统设置 ───────────────────────────────────────────────────
export const mockSecurityPolicy: SecurityPolicy = {
passwordMinLength: 8,
passwordRequireSpecial: true,
passwordRequireUppercase: true,
sessionTimeoutMinutes: 30,
forcePasswordChange: false,
};
export const mockFileUploadConfig: FileUploadConfig = {
maxFileSize: 50,
allowedTypes: ["pdf", "jpg", "jpeg", "png", "docx", "xlsx", "pptx", "mp4"],
};
export const mockNotificationConfig: NotificationConfig = {
notifyNewUser: true,
notifyScheduleChange: true,
notifyAnnouncement: false,
};
export const mockBrandConfig: BrandConfig = {
logoUrl: "/logo.png",
primaryColor: "#1a56db",
schoolMotto: "博学笃行,求实创新",
};

View File

@@ -21,6 +21,25 @@ import {
mockAuditLogs,
mockDashboard,
mockSystemSettings,
mockSchools,
mockGrades,
mockGradeInsights,
mockDepartments,
mockAcademicYears,
mockAuditOverviewStats,
mockAuditTrend,
mockDataChangeLogs,
mockLoginLogs,
mockAnnouncements,
mockInvitationCodes,
mockFiles,
mockFileStats,
mockAiProviders,
mockAiUsage,
mockSecurityPolicy,
mockFileUploadConfig,
mockNotificationConfig,
mockBrandConfig,
genId,
} from "./fixtures";
import type {
@@ -45,6 +64,18 @@ const db = {
students: [...mockStudents],
auditLogs: [...mockAuditLogs],
systemSettings: { ...mockSystemSettings },
schools: [...mockSchools],
grades: [...mockGrades],
departments: [...mockDepartments],
academicYears: [...mockAcademicYears],
announcements: [...mockAnnouncements],
invitationCodes: [...mockInvitationCodes],
files: [...mockFiles],
aiProviders: [...mockAiProviders],
securityPolicy: { ...mockSecurityPolicy },
fileUploadConfig: { ...mockFileUploadConfig },
notificationConfig: { ...mockNotificationConfig },
brandConfig: { ...mockBrandConfig },
};
function paginate<T>(
@@ -507,6 +538,263 @@ export const handlers = [
});
}
// ── auditOverviewStats ──
if (operationName === "AuditOverviewStats") {
return HttpResponse.json({
data: { auditOverviewStats: mockAuditOverviewStats },
});
}
// ── auditTrend ──
if (operationName === "AuditTrend") {
return HttpResponse.json({ data: { auditTrend: mockAuditTrend } });
}
// ── dataChangeLogs ──
if (operationName === "DataChangeLogs") {
return HttpResponse.json({
data: {
dataChangeLogs: paginate(mockDataChangeLogs, variables.filter ?? {}),
},
});
}
// ── loginLogs ──
if (operationName === "LoginLogs") {
return HttpResponse.json({
data: { loginLogs: paginate(mockLoginLogs, variables.filter ?? {}) },
});
}
// ── adminSchools ──
if (operationName === "AdminSchools") {
return HttpResponse.json({ data: { adminSchools: db.schools } });
}
// ── adminGrades ──
if (operationName === "AdminGrades") {
const schoolId = variables.schoolId;
const grades = schoolId
? db.grades.filter((g) => g.schoolId === schoolId)
: db.grades;
return HttpResponse.json({ data: { adminGrades: grades } });
}
// ── adminGradeInsights ──
if (operationName === "AdminGradeInsights") {
return HttpResponse.json({
data: { adminGradeInsights: mockGradeInsights },
});
}
// ── adminDepartments ──
if (operationName === "AdminDepartments") {
const schoolId = variables.schoolId;
const depts = schoolId
? db.departments.filter((d) => d.schoolId === schoolId)
: db.departments;
return HttpResponse.json({ data: { adminDepartments: depts } });
}
// ── adminAcademicYears ──
if (operationName === "AdminAcademicYears") {
return HttpResponse.json({
data: { adminAcademicYears: db.academicYears },
});
}
// ── adminAnnouncements ──
if (operationName === "AdminAnnouncements") {
return HttpResponse.json({
data: {
adminAnnouncements: paginate(
db.announcements,
variables.filter ?? {},
),
},
});
}
// ── createAnnouncement ──
if (operationName === "CreateAnnouncement") {
const input = variables.input;
const newAnn = {
id: genId("ann"),
title: input.title,
content: input.content,
status: "draft" as const,
audience: input.audience ?? ["all"],
authorName: mockCurrentUser.name,
isPinned: false,
readCount: 0,
totalCount: 0,
publishedAt: null,
createdAt: Date.now(),
updatedAt: Date.now(),
};
db.announcements.unshift(newAnn);
recordAudit("ANNOUNCEMENT_CREATE", "announcement", newAnn.id);
return HttpResponse.json({
data: {
createAnnouncement: {
id: newAnn.id,
title: newAnn.title,
status: newAnn.status,
},
},
});
}
// ── publishAnnouncement ──
if (operationName === "PublishAnnouncement") {
const ann = db.announcements.find((a) => a.id === variables.id);
if (ann) {
ann.status = "published";
ann.publishedAt = Date.now();
ann.updatedAt = Date.now();
recordAudit("ANNOUNCEMENT_PUBLISH", "announcement", ann.id);
}
return HttpResponse.json({
data: {
publishAnnouncement: { id: variables.id, status: "published" },
},
});
}
// ── archiveAnnouncement ──
if (operationName === "ArchiveAnnouncement") {
const ann = db.announcements.find((a) => a.id === variables.id);
if (ann) {
ann.status = "archived";
ann.updatedAt = Date.now();
recordAudit("ANNOUNCEMENT_ARCHIVE", "announcement", ann.id);
}
return HttpResponse.json({
data: { archiveAnnouncement: { id: variables.id, status: "archived" } },
});
}
// ── toggleAnnouncementPin ──
if (operationName === "ToggleAnnouncementPin") {
const ann = db.announcements.find((a) => a.id === variables.id);
if (ann) {
ann.isPinned = !ann.isPinned;
ann.updatedAt = Date.now();
}
return HttpResponse.json({
data: {
toggleAnnouncementPin: {
id: variables.id,
isPinned: ann?.isPinned ?? false,
},
},
});
}
// ── adminInvitationCodes ──
if (operationName === "AdminInvitationCodes") {
return HttpResponse.json({
data: {
adminInvitationCodes: paginate(
db.invitationCodes,
variables.filter ?? {},
),
},
});
}
// ── generateInvitationCodes ──
if (operationName === "GenerateInvitationCodes") {
const input = variables.input;
const count = Math.min(input.count ?? 10, 1000);
const batchId = genId("bat");
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const codes: string[] = [];
for (let i = 0; i < count; i++) {
let code = "";
for (let j = 0; j < 8; j++) {
code += charset[Math.floor(Math.random() * charset.length)];
}
codes.push(code);
db.invitationCodes.unshift({
id: genId("ic"),
code,
status: "unused",
roleId: input.roleId ?? null,
roleName: null,
classId: input.classId ?? null,
className: null,
email: input.email ?? null,
batchId,
usedBy: null,
usedByName: null,
usedAt: null,
createdAt: Date.now(),
expiresAt: input.expiresAt ?? null,
});
}
recordAudit("INVITATION_CODE_GENERATE", "invitation_code", batchId);
return HttpResponse.json({
data: { generateInvitationCodes: { codes, batchId } },
});
}
// ── deleteInvitationCodes ──
if (operationName === "DeleteInvitationCodes") {
const ids = variables.ids as string[];
db.invitationCodes = db.invitationCodes.filter(
(ic) => !ids.includes(ic.id),
);
recordAudit("INVITATION_CODE_DELETE", "invitation_code", ids.join(","));
return HttpResponse.json({ data: { deleteInvitationCodes: true } });
}
// ── adminFiles ──
if (operationName === "AdminFiles") {
return HttpResponse.json({
data: { adminFiles: paginate(db.files, variables.filter ?? {}) },
});
}
// ── adminFileStats ──
if (operationName === "AdminFileStats") {
return HttpResponse.json({ data: { adminFileStats: mockFileStats } });
}
// ── deleteFiles ──
if (operationName === "DeleteFiles") {
const ids = variables.ids as string[];
db.files = db.files.filter((f) => !ids.includes(f.id));
recordAudit("FILE_DELETE", "file", ids.join(","));
return HttpResponse.json({ data: { deleteFiles: true } });
}
// ── aiProviders ──
if (operationName === "AiProviders") {
return HttpResponse.json({ data: { aiProviders: db.aiProviders } });
}
// ── updateAiProvider ──
if (operationName === "UpdateAiProvider") {
const provider = db.aiProviders.find((p) => p.id === variables.id);
if (provider) {
Object.assign(provider, variables.input);
}
return HttpResponse.json({
data: {
updateAiProvider: {
id: variables.id,
isActive: provider?.isActive ?? false,
},
},
});
}
// ── aiUsage ──
if (operationName === "AiUsage") {
return HttpResponse.json({ data: { aiUsage: mockAiUsage } });
}
// ── 未匹配的 operation ──
return HttpResponse.json(
{ errors: [{ message: `未实现的 GraphQL operation: ${operationName}` }] },

View File

@@ -0,0 +1,22 @@
/**
* Vitest 全局测试环境配置
*
* - 注册 @testing-library/jest-dom 自定义匹配器
* - 提供 jsdom 环境下的 localStorage / fetch mock
*/
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
// 每个测试后清理 React DOM
afterEach(() => {
cleanup();
});
// 每个测试前重置 localStorage
beforeEach(() => {
localStorage.clear();
});
// mock fetch用于 auth.ts login 测试)
global.fetch = vi.fn() as unknown as typeof fetch;

View File

@@ -205,3 +205,270 @@ export interface WsNotification {
severity: "info" | "warning" | "error";
timestamp: number;
}
// ─── 学校组织管理细化 ──────────────────────────────────────────────
/** 学校视图模型 */
export interface SchoolViewModel {
id: string;
name: string;
code: string;
address: string;
contactPhone: string;
contactEmail: string;
principalName: string | null;
gradeCount: number;
classCount: number;
studentCount: number;
teacherCount: number;
createdAt: number;
}
/** 年级视图模型 */
export interface GradeViewModel {
id: string;
name: string;
schoolId: string;
schoolName: string;
directorName: string | null;
classCount: number;
studentCount: number;
sortOrder: number;
createdAt: number;
}
/** 年级概览统计 */
export interface GradeOverviewStats {
gradeId: string;
gradeName: string;
classCount: number;
studentCount: number;
teacherCount: number;
avgScore: number;
homeworkCount: number;
}
/** 年级洞察视图模型 */
export interface GradeInsightsViewModel {
gradeId: string;
gradeName: string;
schoolWideSummary: {
totalStudents: number;
totalClasses: number;
avgScore: number;
passingRate: number;
};
homeworkTimeline: Array<{
homeworkId: string;
title: string;
className: string;
avgScore: number;
submittedCount: number;
totalCount: number;
dueDate: string;
}>;
classRankings: Array<{
classId: string;
className: string;
avgScore: number;
rank: number;
delta: number;
}>;
}
/** 部门/教研组视图模型 */
export interface DepartmentViewModel {
id: string;
name: string;
schoolId: string;
schoolName: string;
leaderName: string | null;
memberCount: number;
subject: string | null;
createdAt: number;
}
/** 学年/学期视图模型 */
export interface AcademicYearViewModel {
id: string;
name: string;
startDate: string;
endDate: string;
firstSemesterStart: string;
firstSemesterEnd: string;
secondSemesterStart: string;
secondSemesterEnd: string;
isCurrent: boolean;
createdAt: number;
}
// ─── 审计日志子模块 ─────────────────────────────────────────────────
/** 审计总览统计 */
export interface AuditOverviewStats {
totalEvents: number;
todayEvents: number;
uniqueActors: number;
failedActions: number;
topActions: Array<{ action: string; count: number }>;
topResources: Array<{ resource: string; count: number }>;
}
/** 审计趋势数据点 */
export interface AuditTrendPoint {
date: string;
count: number;
action: string;
}
/** 数据变更日志视图模型 */
export interface DataChangeLogViewModel {
id: string;
occurredAt: number;
actorUserId: string;
actorName: string;
tableName: string;
action: "create" | "update" | "delete";
recordId: string;
beforeState: string | null;
afterState: string | null;
ip: string;
}
/** 登录日志视图模型 */
export interface LoginLogViewModel {
id: string;
occurredAt: number;
userId: string;
userName: string;
action: "signin" | "signout" | "signup";
status: "success" | "failure";
ip: string;
userAgent: string;
failureReason: string | null;
}
// ─── 公告管理 ───────────────────────────────────────────────────────
/** 公告视图模型 */
export interface AnnouncementViewModel {
id: string;
title: string;
content: string;
status: "draft" | "published" | "archived";
audience: string[];
authorName: string;
isPinned: boolean;
readCount: number;
totalCount: number;
publishedAt: number | null;
createdAt: number;
updatedAt: number;
}
// ─── 邀请码管理 ─────────────────────────────────────────────────────
/** 邀请码视图模型 */
export interface InvitationCodeViewModel {
id: string;
code: string;
status: "unused" | "used" | "expired";
roleId: string | null;
roleName: string | null;
classId: string | null;
className: string | null;
email: string | null;
batchId: string;
usedBy: string | null;
usedByName: string | null;
usedAt: number | null;
createdAt: number;
expiresAt: number | null;
}
// ─── 文件管理 ───────────────────────────────────────────────────────
/** 文件视图模型 */
export interface FileViewModel {
id: string;
filename: string;
mimeType: string;
size: number;
url: string;
entityType: string;
entityId: string;
uploadedByName: string;
createdAt: number;
}
/** 文件统计 */
export interface FileStats {
totalFiles: number;
totalSize: number;
byMimeType: Array<{ mimeType: string; count: number; size: number }>;
byEntityType: Array<{ entityType: string; count: number }>;
}
// ─── AI 配置 ─────────────────────────────────────────────────────────
/** AI Provider 视图模型 */
export interface AiProviderViewModel {
id: string;
name: string;
type: string;
isPublic: boolean;
isActive: boolean;
model: string;
apiBase: string;
createdBy: string;
createdAt: number;
}
/** AI 用量视图模型 */
export interface AiUsageViewModel {
totalRequests: number;
totalTokens: number;
totalCost: number;
byProvider: Array<{
providerName: string;
requests: number;
tokens: number;
cost: number;
}>;
byDay: Array<{
date: string;
requests: number;
tokens: number;
}>;
}
// ─── 增强系统设置 ───────────────────────────────────────────────────
/** 安全策略配置 */
export interface SecurityPolicy {
passwordMinLength: number;
passwordRequireSpecial: boolean;
passwordRequireUppercase: boolean;
sessionTimeoutMinutes: number;
forcePasswordChange: boolean;
}
/** 文件上传配置 */
export interface FileUploadConfig {
maxFileSize: number;
allowedTypes: string[];
}
/** 通知配置 */
export interface NotificationConfig {
notifyNewUser: boolean;
notifyScheduleChange: boolean;
notifyAnnouncement: boolean;
}
/** 品牌配置 */
export interface BrandConfig {
logoUrl: string;
primaryColor: string;
schoolMotto: string;
}

View File

@@ -238,9 +238,207 @@ gantt
## §5 风险跟踪
| 风险 | 影响 | 缓解 | 状态 |
| ---- | ---- | ---- | ---- |
| teacher-bff admin namespace schema 缺失ISSUE-005 | P3+ 全部业务页阻塞 | 提请 coord 仲裁 ai03 在 P3 启动前补齐P2 用 MSW mock 推进 | ⏳ 待仲裁 |
| 01/02 文档 REST/3003/ai07 与仲裁不一致 | 误导实现 | 已提 7 项异议ISSUE-001~007以 contract.md 为修订基准 | ⏳ 待仲裁 |
| Shell 延迟暴露 GraphQLProvider | P2 骨架阻塞 | P2 用独立壳 + mock providersShell 就绪后切换 | ⏳ |
| MF SSR 对齐复杂 | Remote SSR 上下文依赖 Shell | 优先 CSRSSR 仅首屏 dashboard | ⏳ |
| 风险 | 影响 | 缓解 | 状态 |
| ---------------------------------------------------- | --------------------------- | ---------------------------------------------------------- | --------- |
| teacher-bff admin namespace schema 缺失ISSUE-005 | P3+ 全部业务页阻塞 | 提请 coord 仲裁 ai03 在 P3 启动前补齐P2 用 MSW mock 推进 | ⏳ 待仲裁 |
| 01/02 文档 REST/3003/ai07 与仲裁不一致 | 误导实现 | 已提 7 项异议ISSUE-001~007以 contract.md 为修订基准 | ⏳ 待仲裁 |
| Shell 延迟暴露 GraphQLProvider | P2 骨架阻塞 | P2 用独立壳 + mock providersShell 就绪后切换 | ⏳ |
| MF SSR 对齐复杂 | Remote SSR 上下文依赖 Shell | 优先 CSRSSR 仅首屏 dashboard | ⏳ |
---
## §6 工作进度更新2026-07-13
### 6.1 P2-P6 完成状态
| 阶段 | 任务 | 状态 |
| ---- | ------------------------------------------- | -------------------------------------------------------------------------------------- |
| P2 | 16.1 MF Remote 骨架 | ✅ 完成 |
| P2 | 16.2 MSW handlers + fixtures + mock JWT | ✅ 完成 |
| P2 | 16.3 接入 Shell 共享 | ✅ 完成 |
| P3 | 16.4 用户管理页 | ✅ 完成 |
| P3 | 16.5 角色权限矩阵 | ✅ 完成 |
| P3 | 16.6 权限点管理 + 视口配置 | ✅ 完成 |
| P4 | 16.7 组织树管理 | ✅ 完成 |
| P4 | 16.8 学校设置 | ✅ 完成 |
| P4 | 16.9 班级/教师/学生全局管理 | ✅ 完成 |
| P5 | 16.10 审计日志页 | ✅ 完成 |
| P5 | 16.11 WebSocket 实时通知 | ✅ 完成 |
| P5 | 16.12 管理仪表盘 | ✅ 完成 |
| P6 | 16.13 A11y WCAG 2.2 AA | ✅ 完成 |
| P6 | 16.14 Web Vitals | ✅ 完成 |
| P6 | 16.15 Vitest 单测 | ✅ 完成5 测试文件 / 69 用例全过setup + permissions + i18n + auth + ui + fixtures |
| P6 | 16.16 Dockerfile + /api/health + /api/ready | ✅ 完成 |
### 6.2 coord 仲裁状态
- ARB-021 全部 7 项 ISSUE 已裁决ISSUE-001~007无新仲裁待处理
- ARB-022api-gateway影响 admin-portal 路由配置(`/api/admin/*` → teacher-bff已确认
### 6.3 ISSUE 状态
| ISSUE | 内容 | 状态 |
| --------- | ------------------------------------- | --------- |
| ISSUE-001 | 01/02 模块文档归属错误ai07 → ai16 | ✅ 已裁决 |
| ISSUE-002 | 01/02 端口错误3003 → 4003 | ✅ 已裁决 |
| ISSUE-003 | 01/02 通信协议 REST → GraphQL | ✅ 已裁决 |
| ISSUE-004 | 01/02 遗漏审计日志与学校设置 | ✅ 已裁决 |
| ISSUE-005 | teacher-bff admin 命名空间 schema | ✅ 已裁决 |
| ISSUE-006 | 推送策略轮询 → WebSocket | ✅ 已裁决 |
| ISSUE-007 | matrix.md Kafka 直消费方修正 | ✅ 已裁决 |
---
## §7 参考项目差距分析2026-07-13
> 参考项目:`e:\Desktop\CICD\`(单体 Next_Edu 教育平台admin 页面位于 `src/app/(dashboard)/admin/`
### 7.1 参考项目 Admin 页面全览19 个功能模块)
| # | 参考路由 | 功能 | 领域判定 | Edu admin-portal 现状 |
| --- | ----------------------- | ------------------------------------------------------------------------------- | --------------------------- | ---------------------------------------------- |
| 1 | /admin/dashboard | 管理仪表盘 | 管理域 | ✅ 已有 |
| 2 | /admin/users | 用户管理 | 管理域 | ✅ 已有 |
| 3 | /admin/roles | 角色管理 | 管理域 | ✅ 已有 |
| 4 | /admin/permissions | 权限管理 | 管理域 | ✅ 已有 |
| 5 | /admin/audit-logs | 审计日志(含 overview/data-changes/login-logs | 管理域 | ⚠️ 仅有单一操作日志,缺子模块 |
| 6 | /admin/school | 学校管理(含 schools/grades/departments/academic-year/grades/insights/classes | 管理域 | ⚠️ 仅有粗粒度 organization缺子模块 |
| 7 | /admin/settings | 系统设置(安全策略/文件上传/通知/品牌) | 管理域 | ⚠️ 仅有基础学校信息,缺安全/文件/通知/品牌配置 |
| 8 | /admin/announcements | 公告管理 | 管理域 | ❌ 缺失 |
| 9 | /admin/files | 文件管理 | 管理域 | ❌ 缺失 |
| 10 | /admin/invitation-codes | 邀请码管理 | 管理域 | ❌ 缺失 |
| 11 | /admin/ai-settings | AI 配置 | 管理域 | ❌ 缺失 |
| 12 | /admin/attendance | 考勤管理 | 教学域(归 teacher-portal | N/A |
| 13 | /admin/course-plans | 课程计划 | 教学域(归 teacher-portal | N/A |
| 14 | /admin/curriculum-map | 课程图谱 | 教学域(归 teacher-portal | N/A |
| 15 | /admin/elective | 选课管理 | 教学域(归 teacher-portal | N/A |
| 16 | /admin/error-book | 错题本 | 教学域(归 teacher-portal | N/A |
| 17 | /admin/lesson-plans | 备课管理 | 教学域(归 teacher-portal | N/A |
| 18 | /admin/questions | 题库 | 教学域(归 teacher-portal | N/A |
| 19 | /admin/scheduling | 排课 | 教学域(归 teacher-portal | N/A |
### 7.2 缺失功能分类与实现优先级
#### P0 — 审计日志子模块增强
现有 `/admin/audit-logs` 仅有单一操作日志页面,需增加:
- `/admin/audit-logs/overview` — 审计总览(操作趋势图、数据变更分布、统计条)
- `/admin/audit-logs/data-changes` — 数据变更日志(按表名/动作筛选 + 分页 + 导出)
- `/admin/audit-logs/login-logs` — 登录日志signin/signout/signup 成败记录)
#### P0 — 学校组织管理细化
现有 `/admin/organization` 仅有树形组织视图,需增加子页面:
- `/admin/school/schools` — 多学校/校区列表管理
- `/admin/school/grades` — 年级管理(含年级主任分配 + 概览统计)
- `/admin/school/grades/insights` — 年级洞察(全校成绩汇总 + 作业时间线 + 班级排名)
- `/admin/school/departments` — 部门/教研组管理
- `/admin/school/academic-year` — 学年/学期管理
#### P1 — 系统设置增强
现有 `/admin/system` 仅有基础学校信息 + 学期 + 时区/语言,需增加:
- 安全策略 Card密码复杂度/会话超时/强制改密)
- 文件上传配置 Card最大体积/允许类型)
- 通知配置 Card新用户/调课/公告通知开关)
- 品牌配置 CardLogo/主题色)
#### P1 — 公告管理(新增)
- `/admin/announcements` — 公告发布/归档/置顶、多受众 fan-out 通知、已读回执、状态机draft→published→archived
#### P1 — 邀请码管理(新增)
- `/admin/invitation-codes` — 批量生成邀请码、角色/班级/邮箱绑定、事务消费、批次审计
#### P2 — 文件管理(新增)
- `/admin/files` — 全局文件附件治理、跨业务实体筛选、批量删除、存储统计、预览
#### P2 — AI 配置(新增)
- `/admin/ai-settings` — AI Provider 凭据管理、用量仪表盘、public/private provider 分治
### 7.3 实现计划
| 批次 | 内容 | 优先级 |
| ------ | ------------------------------------------------------------------------------------------------------------------------- | ------- |
| 批次 1 | 审计日志子模块overview/data-changes/login-logs+ 学校组织管理细化schools/grades/departments/academic-year/insights | P0 |
| 批次 2 | 系统设置增强 + 公告管理 + 邀请码管理 | P1 |
| 批次 3 | 文件管理 + AI 配置 | P2 |
| 批次 4 | 测试文件补充vitest setup + test cases ≥80% | P6 补齐 |
---
## §8 差距闭环完成总结2026-07-13
### 8.1 全部 4 批次已交付
| 批次 | 交付内容 | 状态 |
| ----------------- | ------------------------------------------------------------------------------ | ------- |
| 批次 1P0 | 审计子模块 3 页 + 学校组织 7 页(含 insights | ✅ 完成 |
| 批次 2P1 | 系统设置 4 Card 增强 + 公告管理 + 邀请码管理 | ✅ 完成 |
| 批次 3P2 | 文件管理 + AI 配置 | ✅ 完成 |
| 批次 4P6 补齐) | 5 个测试文件setup + permissions + i18n + auth + ui + fixtures69 用例全过 | ✅ 完成 |
### 8.2 新增文件清单
**Hooks7 个)**
- `src/hooks/use-audit-overview.ts` — 审计总览统计 + 7 日趋势
- `src/hooks/use-data-change-logs.ts` — 数据变更日志 + CSV 导出
- `src/hooks/use-login-logs.ts` — 登录日志查询
- `src/hooks/use-school.ts` — 学校/年级/部门/学年/年级洞察
- `src/hooks/use-announcements.ts` — 公告 CRUD + 状态机
- `src/hooks/use-invitation-codes.ts` — 邀请码批量生成/删除
- `src/hooks/use-files.ts` — 文件列表/统计/批量删除
- `src/hooks/use-ai-settings.ts` — AI Provider + 用量
**Pages13 个新建 + 2 个修改)**
- 审计子模块:`audit-logs/overview/page.tsx``audit-logs/data-changes/page.tsx``audit-logs/login-logs/page.tsx``audit-logs/page.tsx`(修改加 Tabs
- 学校组织:`school/layout.tsx``school/page.tsx`(重定向)、`school/schools/page.tsx``school/grades/page.tsx``school/grades/insights/page.tsx``school/departments/page.tsx``school/academic-year/page.tsx`
- 公告/邀请码:`announcements/page.tsx``invitation-codes/page.tsx`
- 文件/AI`files/page.tsx``ai-settings/page.tsx`
- 系统设置(修改):`system/page.tsx` 增加 4 Card
**共享组件**`src/components/audit-logs-tabs.tsx`
**测试文件5 个)**
- `src/test/setup.ts` — 全局环境jest-dom + localStorage + fetch mock
- `src/lib/permissions.test.ts` — 8 用例
- `src/lib/i18n.test.ts` — 9 用例
- `src/lib/auth.test.ts` — 9 用例
- `src/components/ui.test.tsx` — 27 用例PaperCard/PageHeader/Button/Input/Select/Badge/Table/EmptyState/LoadingState/ErrorState/Pagination
- `src/mocks/fixtures.test.ts` — 16 用例
### 8.3 共享基础设施扩展
| 文件 | 增量 |
| -------------------------------- | ------------------------------------ |
| `src/lib/permissions.ts` | 新增 11 个权限点 + 14 条路由权限映射 |
| `src/types/view-models.ts` | 新增 ~20 个 ViewModel 接口 |
| `src/lib/i18n.ts` | 新增 ~100 个 i18n key |
| `src/lib/graphql-client.ts` | 新增 ~25 个 GraphQL operation |
| `src/mocks/fixtures.ts` | 新增 ~15 个 mock 数据集合 |
| `src/mocks/handlers.ts` | 新增 ~20 个 operation handler |
| `src/components/admin-shell.tsx` | NAV_ITEMS 11 → 16 项 |
### 8.4 质量校验
-`pnpm run typecheck`:零错误
-`pnpm run lint`:零错误
-`pnpm run test`5 文件 / 69 用例全过11.76s
-`pnpm run arch:scan`arch.db 已更新TS 20 模块 / 2045 符号)
### 8.5 ARB-021 全 7 项 ISSUE 状态
全部已裁决,无新 coord 仲裁(最新为 ARB-022 §24属 api-gateway 模块)。
### 8.6 模块完成度
admin-portal 全部 P2-P6 任务16 项)+ §7 差距分析 4 批次补齐全部完成,模块工作收口。