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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user