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:
271
apps/admin-portal/src/app/admin/ai-settings/page.tsx
Normal file
271
apps/admin-portal/src/app/admin/ai-settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
406
apps/admin-portal/src/app/admin/announcements/page.tsx
Normal file
406
apps/admin-portal/src/app/admin/announcements/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
apps/admin-portal/src/app/admin/audit-logs/data-changes/page.tsx
Normal file
186
apps/admin-portal/src/app/admin/audit-logs/data-changes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
apps/admin-portal/src/app/admin/audit-logs/login-logs/page.tsx
Normal file
171
apps/admin-portal/src/app/admin/audit-logs/login-logs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
apps/admin-portal/src/app/admin/audit-logs/overview/page.tsx
Normal file
199
apps/admin-portal/src/app/admin/audit-logs/overview/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
334
apps/admin-portal/src/app/admin/files/page.tsx
Normal file
334
apps/admin-portal/src/app/admin/files/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
538
apps/admin-portal/src/app/admin/invitation-codes/page.tsx
Normal file
538
apps/admin-portal/src/app/admin/invitation-codes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
apps/admin-portal/src/app/admin/school/academic-year/page.tsx
Normal file
106
apps/admin-portal/src/app/admin/school/academic-year/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/admin-portal/src/app/admin/school/departments/page.tsx
Normal file
77
apps/admin-portal/src/app/admin/school/departments/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
250
apps/admin-portal/src/app/admin/school/grades/insights/page.tsx
Normal file
250
apps/admin-portal/src/app/admin/school/grades/insights/page.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
apps/admin-portal/src/app/admin/school/grades/page.tsx
Normal file
85
apps/admin-portal/src/app/admin/school/grades/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
apps/admin-portal/src/app/admin/school/layout.tsx
Normal file
87
apps/admin-portal/src/app/admin/school/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/admin-portal/src/app/admin/school/page.tsx
Normal file
5
apps/admin-portal/src/app/admin/school/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SchoolPage(): never {
|
||||
redirect("/admin/school/schools");
|
||||
}
|
||||
103
apps/admin-portal/src/app/admin/school/schools/page.tsx
Normal file
103
apps/admin-portal/src/app/admin/school/schools/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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") },
|
||||
];
|
||||
|
||||
65
apps/admin-portal/src/components/audit-logs-tabs.tsx
Normal file
65
apps/admin-portal/src/components/audit-logs-tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
apps/admin-portal/src/components/ui.test.tsx
Normal file
267
apps/admin-portal/src/components/ui.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
59
apps/admin-portal/src/hooks/use-ai-settings.ts
Normal file
59
apps/admin-portal/src/hooks/use-ai-settings.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AI 配置 Hooks
|
||||
*
|
||||
* contract §2.4:aiProviders / 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,
|
||||
};
|
||||
}
|
||||
128
apps/admin-portal/src/hooks/use-announcements.ts
Normal file
128
apps/admin-portal/src/hooks/use-announcements.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 公告管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminAnnouncements / 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;
|
||||
}
|
||||
39
apps/admin-portal/src/hooks/use-audit-overview.ts
Normal file
39
apps/admin-portal/src/hooks/use-audit-overview.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 审计总览 Hooks
|
||||
*
|
||||
* contract §2.4:auditOverviewStats / 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,
|
||||
};
|
||||
}
|
||||
97
apps/admin-portal/src/hooks/use-data-change-logs.ts
Normal file
97
apps/admin-portal/src/hooks/use-data-change-logs.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 数据变更日志 Hooks
|
||||
*
|
||||
* contract §2.4:dataChangeLogs
|
||||
*/
|
||||
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);
|
||||
}
|
||||
102
apps/admin-portal/src/hooks/use-files.ts
Normal file
102
apps/admin-portal/src/hooks/use-files.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 文件管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminFiles / 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 };
|
||||
}
|
||||
99
apps/admin-portal/src/hooks/use-invitation-codes.ts
Normal file
99
apps/admin-portal/src/hooks/use-invitation-codes.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 邀请码管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminInvitationCodes / 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;
|
||||
}
|
||||
61
apps/admin-portal/src/hooks/use-login-logs.ts
Normal file
61
apps/admin-portal/src/hooks/use-login-logs.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 登录日志 Hooks
|
||||
*
|
||||
* contract §2.4:loginLogs
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
99
apps/admin-portal/src/hooks/use-school.ts
Normal file
99
apps/admin-portal/src/hooks/use-school.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 学校组织管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminSchools / 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 ?? [],
|
||||
};
|
||||
}
|
||||
157
apps/admin-portal/src/lib/auth.test.ts
Normal file
157
apps/admin-portal/src/lib/auth.test.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
54
apps/admin-portal/src/lib/i18n.test.ts
Normal file
54
apps/admin-portal/src/lib/i18n.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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": "校训",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
92
apps/admin-portal/src/lib/permissions.test.ts
Normal file
92
apps/admin-portal/src/lib/permissions.test.ts
Normal 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_");
|
||||
});
|
||||
});
|
||||
@@ -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) */
|
||||
|
||||
148
apps/admin-portal/src/mocks/fixtures.test.ts
Normal file
148
apps/admin-portal/src/mocks/fixtures.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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: "博学笃行,求实创新",
|
||||
};
|
||||
|
||||
@@ -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}` }] },
|
||||
|
||||
22
apps/admin-portal/src/test/setup.ts
Normal file
22
apps/admin-portal/src/test/setup.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -239,8 +239,206 @@ 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 providers,Shell 就绪后切换 | ⏳ |
|
||||
| MF SSR 对齐复杂 | Remote SSR 上下文依赖 Shell | 优先 CSR,SSR 仅首屏 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-022(api-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(新用户/调课/公告通知开关)
|
||||
- 品牌配置 Card(Logo/主题色)
|
||||
|
||||
#### 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 批次已交付
|
||||
|
||||
| 批次 | 交付内容 | 状态 |
|
||||
| ----------------- | ------------------------------------------------------------------------------ | ------- |
|
||||
| 批次 1(P0) | 审计子模块 3 页 + 学校组织 7 页(含 insights) | ✅ 完成 |
|
||||
| 批次 2(P1) | 系统设置 4 Card 增强 + 公告管理 + 邀请码管理 | ✅ 完成 |
|
||||
| 批次 3(P2) | 文件管理 + AI 配置 | ✅ 完成 |
|
||||
| 批次 4(P6 补齐) | 5 个测试文件(setup + permissions + i18n + auth + ui + fixtures),69 用例全过 | ✅ 完成 |
|
||||
|
||||
### 8.2 新增文件清单
|
||||
|
||||
**Hooks(7 个)**:
|
||||
|
||||
- `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 + 用量
|
||||
|
||||
**Pages(13 个新建 + 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 批次补齐全部完成,模块工作收口。
|
||||
|
||||
Reference in New Issue
Block a user