feat(admin-portal): 完整实现 admin-portal 管理端微前端

包含 src 全部实现、Dockerfile、配置文件等
This commit is contained in:
SpecialX
2026-07-10 19:09:12 +08:00
parent 02d09c47fa
commit b3511910d1
69 changed files with 11955 additions and 693 deletions

View File

@@ -0,0 +1,158 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import {
useAuditLogs,
useAuditLogFilter,
exportAuditLogsCsv,
} from "@/hooks/use-audit-logs";
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";
export default function AuditLogsPage(): ReactNode {
const { filter, setPage, setSearch, setAction } = useAuditLogFilter();
const { data, loading, error } = useAuditLogs(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;
}
exportAuditLogsCsv(data.items);
show("success", "已导出 CSV");
};
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.auditLogs.title")}
description="系统所有操作的审计记录"
actions={
<Button variant="secondary" onClick={handleExport}>
{t("admin.auditLogs.exportCsv")}
</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.action ?? ""}
onChange={(e) => setAction(e.target.value)}
>
<option value="">{t("admin.common.all")}</option>
<option value="USER_LOGIN"></option>
<option value="USER_CREATE"></option>
<option value="USER_UPDATE"></option>
<option value="USER_TOGGLE_STATUS"></option>
<option value="ROLE_CREATE"></option>
<option value="ROLE_UPDATE"></option>
<option value="VIEWPORT_UPDATE"></option>
<option value="SYSTEM_SETTINGS_UPDATE"></option>
<option value="ABNORMAL_LOGIN_BLOCKED"></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.action"),
t("admin.auditLogs.resource"),
t("admin.auditLogs.resourceId"),
t("admin.auditLogs.ip"),
t("admin.auditLogs.traceId"),
]}
>
{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>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{log.action}
</span>
</TableCell>
<TableCell>{log.resourceType}</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.resourceId}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.ip}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-ink-muted)",
}}
>
{log.traceId ?? "—"}
</TableCell>
</TableRow>
))}
</Table>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import { useClasses, useClassFilter } from "@/hooks/use-classes";
import {
PageHeader,
PaperCard,
Input,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Pagination,
} from "@/components/ui";
import { t } from "@/lib/i18n";
export default function ClassesPage(): ReactNode {
const { filter, setPage, setSearch } = useClassFilter();
const { data, loading, error } = useClasses(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.classes.title")} description="全校班级一览" />
<PaperCard className="p-4 mb-4">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
</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.classes.headTeacher"),
t("admin.classes.studentCount"),
t("admin.common.createdAt"),
]}
>
{data.items.map((cls) => (
<TableRow key={cls.id}>
<TableCell>{cls.name}</TableCell>
<TableCell>{cls.gradeName}</TableCell>
<TableCell>{cls.schoolName}</TableCell>
<TableCell>{cls.headTeacherName || "—"}</TableCell>
<TableCell>{cls.studentCount}</TableCell>
<TableCell>
{new Date(cls.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
</TableRow>
))}
</Table>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
</div>
);
}

View File

@@ -0,0 +1,267 @@
"use client";
import { type ReactNode } from "react";
import { useDashboard } from "@/hooks/use-dashboard";
import {
PageHeader,
PaperCard,
LoadingState,
ErrorState,
Badge,
} from "@/components/ui";
import { NotificationPanel } from "@/components/notification-panel";
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 DashboardPage(): ReactNode {
const { data, loading, error } = useDashboard();
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.dashboard.title")}
description="全局概览与系统健康"
/>
<NotificationPanel />
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{data && (
<div className="flex flex-col gap-6">
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<StatCard
label={t("admin.dashboard.totalTeachers")}
value={data.totalTeachers}
/>
<StatCard
label={t("admin.dashboard.totalStudents")}
value={data.totalStudents}
/>
<StatCard
label={t("admin.dashboard.totalClasses")}
value={data.totalClasses}
/>
<StatCard
label={t("admin.dashboard.totalSchools")}
value={data.totalSchools}
/>
<StatCard
label={t("admin.dashboard.schoolAvgScore")}
value={data.schoolAvgScore.toFixed(1)}
/>
<StatCard
label={t("admin.dashboard.activeUsersToday")}
value={data.activeUsersToday}
/>
<StatCard
label={t("admin.dashboard.auditEventsToday")}
value={data.auditEventsToday}
/>
</div>
{/* 趋势图(简化的表格) */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.dashboard.trend")}
</h3>
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 12,
}}
>
<thead>
<tr style={{ borderBottom: "1px solid var(--color-rule)" }}>
<th
style={{
textAlign: "left",
padding: "6px 8px",
color: "var(--color-ink-muted)",
}}
>
</th>
<th
style={{
textAlign: "right",
padding: "6px 8px",
color: "var(--color-ink-muted)",
}}
>
</th>
<th
style={{
textAlign: "right",
padding: "6px 8px",
color: "var(--color-ink-muted)",
}}
>
</th>
<th
style={{
textAlign: "right",
padding: "6px 8px",
color: "var(--color-ink-muted)",
}}
>
</th>
</tr>
</thead>
<tbody>
{data.trend.map((row) => (
<tr
key={row.date}
style={{ borderBottom: "1px solid var(--color-rule)" }}
>
<td
style={{
padding: "6px 8px",
color: "var(--color-ink)",
}}
>
{row.date}
</td>
<td
style={{
padding: "6px 8px",
textAlign: "right",
color: "var(--color-ink)",
}}
>
{row.teachers}
</td>
<td
style={{
padding: "6px 8px",
textAlign: "right",
color: "var(--color-ink)",
}}
>
{row.students}
</td>
<td
style={{
padding: "6px 8px",
textAlign: "right",
color: "var(--color-ink)",
fontFamily: "var(--font-mono)",
}}
>
{row.avgScore.toFixed(1)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</PaperCard>
{/* 服务健康 */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.dashboard.serviceHealth")}
</h3>
<div className="grid grid-cols-2 gap-3">
{data.serviceHealth.map((svc) => (
<div
key={svc.serviceName}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px 12px",
borderBottom: "1px solid var(--color-rule)",
}}
>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "var(--color-ink)",
}}
>
{svc.serviceName}
</span>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span
style={{ fontSize: 11, color: "var(--color-ink-muted)" }}
>
{svc.latencyMs}ms
</span>
<Badge status={svc.status} />
</div>
</div>
))}
</div>
</PaperCard>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { type ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { GraphQLProvider } from "@/providers/graphql-provider";
import { AuthProvider, useAuth } from "@/providers/auth-provider";
import { ToastProvider } from "@/providers/toast-provider";
import { AdminShell } from "@/components/admin-shell";
import { MswInitializer } from "@/components/msw-initializer";
import { WebVitalsInitializer } from "@/components/web-vitals-initializer";
function AuthGuard({ children }: { children: ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace("/login");
}
}, [isLoading, isAuthenticated, router]);
if (isLoading) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
}}
>
<p style={{ color: "var(--color-ink-muted)" }}>...</p>
</div>
);
}
if (!isAuthenticated) return null;
return <>{children}</>;
}
export default function AdminLayout({ children }: { children: ReactNode }) {
const [mswReady, setMswReady] = useState(false);
return (
<GraphQLProvider>
<WebVitalsInitializer />
<MswInitializer onReady={() => setMswReady(true)} />
{mswReady && (
<AuthProvider>
<ToastProvider>
<AuthGuard>
<AdminShell>{children}</AdminShell>
</AuthGuard>
</ToastProvider>
</AuthProvider>
)}
</GraphQLProvider>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { type ReactNode, useState } from "react";
import { OrganizationTree } from "@/components/organization-tree";
import { PageHeader, PaperCard } from "@/components/ui";
import { t } from "@/lib/i18n";
import type { OrganizationNode } from "@/types/view-models";
export default function OrganizationPage(): ReactNode {
const [selected, setSelected] = useState<OrganizationNode | null>(null);
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.organization.title")}
description="学校 → 年级 → 班级 三级组织架构"
/>
<div className="flex gap-4">
<PaperCard className="p-4" style={{ flex: 1 }}>
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 14,
color: "var(--color-ink-muted)",
marginBottom: 12,
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
</h3>
<OrganizationTree onSelect={setSelected} />
</PaperCard>
<PaperCard className="p-4" style={{ width: 320 }}>
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 14,
color: "var(--color-ink-muted)",
marginBottom: 12,
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
</h3>
{selected ? (
<dl style={{ fontSize: 13, color: "var(--color-ink)" }}>
<div style={{ marginBottom: 8 }}>
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
</dt>
<dd
style={{
margin: 0,
fontFamily: "var(--font-serif)",
fontSize: 16,
}}
>
{selected.name}
</dd>
</div>
<div style={{ marginBottom: 8 }}>
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
</dt>
<dd style={{ margin: 0 }}>
{selected.type === "school"
? "学校"
: selected.type === "grade"
? "年级"
: "班级"}
</dd>
</div>
<div style={{ marginBottom: 8 }}>
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
ID
</dt>
<dd
style={{
margin: 0,
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{selected.id}
</dd>
</div>
<div style={{ marginBottom: 8 }}>
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
</dt>
<dd style={{ margin: 0 }}>{selected.childrenCount}</dd>
</div>
<div style={{ marginBottom: 8 }}>
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
</dt>
<dd style={{ margin: 0 }}>{selected.sortOrder}</dd>
</div>
</dl>
) : (
<p style={{ fontSize: 13, color: "var(--color-ink-muted)" }}>
</p>
)}
</PaperCard>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { type ReactNode, useState } from "react";
import { usePermissions } from "@/hooks/use-permissions";
import {
PageHeader,
PaperCard,
Input,
Select,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Badge,
} from "@/components/ui";
import { t } from "@/lib/i18n";
export default function PermissionsPage(): ReactNode {
const { data: permissions, loading, error } = usePermissions();
const [search, setSearch] = useState("");
const [resourceFilter, setResourceFilter] = useState("all");
const resources = Array.from(new Set(permissions.map((p) => p.resource)));
const filtered = permissions.filter((p) => {
if (resourceFilter !== "all" && p.resource !== resourceFilter) return false;
if (search) {
const q = search.toLowerCase();
return (
p.code.toLowerCase().includes(q) ||
p.name.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q)
);
}
return true;
});
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.permissions.title")}
description="查看所有权限点定义"
/>
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input
type="search"
placeholder={t("admin.common.search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={resourceFilter}
onChange={(e) => setResourceFilter(e.target.value)}
>
<option value="all">{t("admin.common.all")}</option>
{resources.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select>
</div>
</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.permissions.code"),
t("admin.permissions.resource"),
t("admin.permissions.action"),
"名称",
"说明",
"系统",
]}
>
{filtered.map((perm) => (
<TableRow key={perm.id}>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
>
{perm.code}
</TableCell>
<TableCell>{perm.resource}</TableCell>
<TableCell>{perm.action}</TableCell>
<TableCell>{perm.name}</TableCell>
<TableCell
style={{ color: "var(--color-ink-muted)", fontSize: 12 }}
>
{perm.description}
</TableCell>
<TableCell>
{perm.isSystem ? <Badge status="active" /> : "—"}
</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
</div>
);
}

View File

@@ -0,0 +1,291 @@
"use client";
import { type ReactNode, useState } from "react";
import {
useRoles,
useCreateRole,
useUpdateRolePermissions,
} from "@/hooks/use-roles";
import { usePermissions } from "@/hooks/use-permissions";
import { useToast } from "@/providers/toast-provider";
import {
PageHeader,
PaperCard,
Button,
LoadingState,
ErrorState,
Badge,
} from "@/components/ui";
import { RolePermissionMatrix } from "@/components/role-permission-matrix";
import { t } from "@/lib/i18n";
import type { RoleViewModel } from "@/types/view-models";
export default function RolesPage(): ReactNode {
const { data: roles, loading, error } = useRoles();
const { data: permissions } = usePermissions();
const { show } = useToast();
const [createRole] = useCreateRole();
const [updateRolePermissions] = useUpdateRolePermissions();
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [newRoleName, setNewRoleName] = useState("");
const [newRoleCode, setNewRoleCode] = useState("");
const [newRoleDescription, setNewRoleDescription] = useState("");
const [creating, setCreating] = useState(false);
const selectedRole: RoleViewModel | null = selectedRoleId
? (roles.find((r) => r.id === selectedRoleId) ?? null)
: (roles[0] ?? null);
const handleCreateRole = async () => {
if (!newRoleName || !newRoleCode) {
show("warning", "请填写角色名称和代码");
return;
}
setCreating(true);
try {
await createRole({
name: newRoleName,
code: newRoleCode,
description: newRoleDescription,
dataScope: "SCHOOL",
});
show("success", "角色已创建");
setShowCreateForm(false);
setNewRoleName("");
setNewRoleCode("");
setNewRoleDescription("");
window.location.reload();
} catch (err) {
show(
"error",
"创建失败",
err instanceof Error ? err.message : String(err),
);
} finally {
setCreating(false);
}
};
const handleSavePermissions = async (permissionCodes: string[]) => {
if (!selectedRole) return;
try {
await updateRolePermissions(selectedRole.id, permissionCodes);
show("success", "权限已更新");
} 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.roles.title")}
description="管理角色、配置权限矩阵"
actions={
<Button variant="primary" onClick={() => setShowCreateForm(true)}>
{t("admin.roles.new")}
</Button>
}
/>
<div className="flex gap-4">
{/* 左侧:角色列表 */}
<PaperCard className="p-4" style={{ width: 280, flexShrink: 0 }}>
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 14,
color: "var(--color-ink-muted)",
marginBottom: 12,
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
</h3>
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{roles.map((role) => (
<li key={role.id}>
<button
onClick={() => setSelectedRoleId(role.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "8px 12px",
background:
selectedRole?.id === role.id
? "var(--color-accent-light)"
: "transparent",
border: "none",
borderLeft:
selectedRole?.id === role.id
? "2px solid var(--color-accent)"
: "2px solid transparent",
cursor: "pointer",
color: "var(--color-ink)",
fontSize: 13,
}}
>
<div style={{ fontWeight: 500 }}>{role.name}</div>
<div
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
display: "flex",
gap: 6,
alignItems: "center",
}}
>
<span>{role.code}</span>
{role.isSystem && <Badge status="active" />}
</div>
</button>
</li>
))}
</ul>
</PaperCard>
{/* 右侧:权限矩阵 */}
<PaperCard className="p-4" style={{ flex: 1 }}>
{selectedRole && permissions.length > 0 ? (
<RolePermissionMatrix
role={selectedRole}
permissions={permissions}
onSave={handleSavePermissions}
/>
) : (
<LoadingState />
)}
</PaperCard>
</div>
{/* 新建角色对话框 */}
{showCreateForm && (
<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: 400,
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.roles.new")}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
</span>
<input
type="text"
value={newRoleName}
onChange={(e) => setNewRoleName(e.target.value)}
style={{
padding: "6px 10px",
border: "1px solid var(--color-rule)",
borderRadius: 4,
fontSize: 13,
}}
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
</span>
<input
type="text"
value={newRoleCode}
onChange={(e) => setNewRoleCode(e.target.value)}
placeholder="如 grade_leader"
style={{
padding: "6px 10px",
border: "1px solid var(--color-rule)",
borderRadius: 4,
fontSize: 13,
fontFamily: "var(--font-mono)",
}}
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
</span>
<textarea
value={newRoleDescription}
onChange={(e) => setNewRoleDescription(e.target.value)}
rows={3}
style={{
padding: "6px 10px",
border: "1px solid var(--color-rule)",
borderRadius: 4,
fontSize: 13,
}}
/>
</label>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<Button
variant="secondary"
onClick={() => setShowCreateForm(false)}
>
{t("admin.common.cancel")}
</Button>
<Button
variant="primary"
onClick={handleCreateRole}
disabled={creating}
>
{creating
? t("admin.common.loading")
: t("admin.common.save")}
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import { useStudents, useStudentFilter } from "@/hooks/use-students";
import {
PageHeader,
PaperCard,
Input,
Select,
Badge,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Pagination,
} from "@/components/ui";
import { t } from "@/lib/i18n";
export default function StudentsPage(): ReactNode {
const { filter, setPage, setSearch, setStatus } = useStudentFilter();
const { data, loading, error } = useStudents(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.students.title")}
description="全校学生一览"
/>
<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="active">{t("admin.common.active")}</option>
<option value="disabled">{t("admin.common.disabled")}</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.email"),
t("admin.common.name"),
t("admin.common.class"),
t("admin.common.grade"),
t("admin.common.school"),
t("admin.students.guardian"),
t("admin.students.guardianPhone"),
t("admin.common.status"),
]}
>
{data.items.map((student) => (
<TableRow key={student.id}>
<TableCell>{student.email}</TableCell>
<TableCell>{student.name}</TableCell>
<TableCell>{student.className}</TableCell>
<TableCell>{student.gradeName}</TableCell>
<TableCell>{student.schoolName}</TableCell>
<TableCell>{student.guardianName ?? "—"}</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
>
{student.guardianPhone ?? "—"}
</TableCell>
<TableCell>
<Badge status={student.status} />
</TableCell>
</TableRow>
))}
</Table>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
</div>
);
}

View File

@@ -0,0 +1,214 @@
"use client";
import { type ReactNode, useState, useEffect, type FormEvent } from "react";
import {
useSystemSettings,
useUpdateSystemSettings,
} from "@/hooks/use-system-settings";
import { useToast } from "@/providers/toast-provider";
import {
PageHeader,
PaperCard,
Button,
Input,
Select,
LoadingState,
ErrorState,
} from "@/components/ui";
import { t } from "@/lib/i18n";
import type { SystemSettingsViewModel } from "@/types/view-models";
export default function SystemPage(): ReactNode {
const { data, loading, error } = useSystemSettings();
const { show } = useToast();
const [updateSettings] = useUpdateSystemSettings();
const [form, setForm] = useState<SystemSettingsViewModel | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (data && !form) {
setForm(data);
}
}, [data, form]);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!form) return;
setSaving(true);
try {
await updateSettings(form);
show("success", t("admin.system.saveSuccess"));
} catch (err) {
show(
"error",
"保存失败",
err instanceof Error ? err.message : String(err),
);
} finally {
setSaving(false);
}
};
const update = (field: keyof SystemSettingsViewModel, value: string) => {
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
};
return (
<div style={{ padding: 32, maxWidth: 720, margin: "0 auto" }}>
<PageHeader
title={t("admin.system.title")}
description="学校基本信息、学期与本地化设置"
/>
<PaperCard className="p-6">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{form && (
<form
onSubmit={handleSubmit}
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
<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.schoolName")}
</span>
<Input
type="text"
value={form.schoolName}
onChange={(e) => update("schoolName", e.target.value)}
required
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.schoolCode")}
</span>
<Input
type="text"
value={form.schoolCode}
onChange={(e) => update("schoolCode", e.target.value)}
required
style={{ fontFamily: "var(--font-mono)" }}
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.academicYear")}
</span>
<Input
type="text"
value={form.academicYear}
onChange={(e) => update("academicYear", e.target.value)}
placeholder="如 2025-2026"
required
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.semester")}
</span>
<Select
value={form.semester}
onChange={(e) => update("semester", e.target.value)}
>
<option value="first"></option>
<option value="second"></option>
</Select>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.contactEmail")}
</span>
<Input
type="email"
value={form.contactEmail}
onChange={(e) => update("contactEmail", e.target.value)}
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.contactPhone")}
</span>
<Input
type="tel"
value={form.contactPhone}
onChange={(e) => update("contactPhone", e.target.value)}
/>
</label>
<label
style={{
display: "flex",
flexDirection: "column",
gap: 4,
gridColumn: "span 2",
}}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.address")}
</span>
<Input
type="text"
value={form.address}
onChange={(e) => update("address", e.target.value)}
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.timezone")}
</span>
<Select
value={form.timezone}
onChange={(e) => update("timezone", e.target.value)}
>
<option value="Asia/Shanghai">Asia/Shanghai</option>
<option value="Asia/Hong_Kong">Asia/Hong_Kong</option>
<option value="UTC">UTC</option>
</Select>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.locale")}
</span>
<Select
value={form.locale}
onChange={(e) => update("locale", e.target.value)}
>
<option value="zh-CN"></option>
<option value="en-US">English (US)</option>
</Select>
</label>
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: 8,
}}
>
<Button variant="primary" type="submit" disabled={saving}>
{saving ? t("admin.common.loading") : t("admin.common.save")}
</Button>
</div>
</form>
)}
</PaperCard>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import { useTeachers, useTeacherFilter } from "@/hooks/use-teachers";
import {
PageHeader,
PaperCard,
Input,
Select,
Badge,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Pagination,
} from "@/components/ui";
import { t } from "@/lib/i18n";
export default function TeachersPage(): ReactNode {
const { filter, setPage, setSearch, setStatus } = useTeacherFilter();
const { data, loading, error } = useTeachers(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.teachers.title")}
description="全校教师一览"
/>
<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="active">{t("admin.common.active")}</option>
<option value="disabled">{t("admin.common.disabled")}</option>
<option value="locked">{t("admin.common.locked")}</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.email"),
t("admin.common.name"),
t("admin.common.school"),
t("admin.teachers.subjects"),
t("admin.teachers.classCount"),
t("admin.common.status"),
t("admin.common.lastLoginAt"),
]}
>
{data.items.map((teacher) => (
<TableRow key={teacher.id}>
<TableCell>{teacher.email}</TableCell>
<TableCell>{teacher.name}</TableCell>
<TableCell>{teacher.schoolName}</TableCell>
<TableCell>
{teacher.subjects.join(", ") || "—"}
</TableCell>
<TableCell>{teacher.classCount}</TableCell>
<TableCell>
<Badge status={teacher.status} />
</TableCell>
<TableCell>
{teacher.lastLoginAt
? new Date(teacher.lastLoginAt).toLocaleDateString(
"zh-CN",
)
: "—"}
</TableCell>
</TableRow>
))}
</Table>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
</div>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import {
useUsers,
useUserFilter,
useCreateUser,
useUpdateUser,
useToggleUserStatus,
} from "@/hooks/use-users";
import { useRoles } from "@/hooks/use-roles";
import { useToast } from "@/providers/toast-provider";
import {
PageHeader,
PaperCard,
Input,
Select,
LoadingState,
ErrorState,
EmptyState,
Pagination,
} from "@/components/ui";
import { UserManagementTable } from "@/components/user-management-table";
import { UserFormModal, type UserFormData } from "@/components/user-form-modal";
import { t } from "@/lib/i18n";
import type { UserViewModel } from "@/types/view-models";
export default function UsersPage(): ReactNode {
const { filter, setPage, setSearch, setStatus } = useUserFilter();
const { data, loading, error } = useUsers(filter);
const { data: roles } = useRoles();
const { show } = useToast();
const [createUser] = useCreateUser();
const [updateUser] = useUpdateUser();
const [toggleStatus] = useToggleUserStatus();
const [modalOpen, setModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserViewModel | null>(null);
const [searchInput, setSearchInput] = useState("");
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(timer);
}, [searchInput, setSearch]);
const handleCreate = () => {
setEditingUser(null);
setModalOpen(true);
};
const handleEdit = (user: UserViewModel) => {
setEditingUser(user);
setModalOpen(true);
};
const handleToggleStatus = async (user: UserViewModel) => {
const newStatus = user.status === "active" ? "disabled" : "active";
try {
await toggleStatus(user.id, newStatus);
show("success", "状态已更新");
window.location.reload();
} catch (err) {
show(
"error",
"操作失败",
err instanceof Error ? err.message : String(err),
);
}
};
const handleSubmit = async (formData: UserFormData) => {
try {
if (editingUser) {
await updateUser(editingUser.id, {
name: formData.name,
email: formData.email,
roleIds: formData.roleIds,
dataScope: formData.dataScope,
});
show("success", "用户已更新");
} else {
await createUser({
email: formData.email,
name: formData.name,
password: formData.password ?? "",
roleIds: formData.roleIds,
dataScope: formData.dataScope,
});
show("success", "用户已创建");
}
setModalOpen(false);
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.users.title")}
description="管理所有用户账号、角色分配与状态"
/>
<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="active">{t("admin.common.active")}</option>
<option value="disabled">{t("admin.common.disabled")}</option>
<option value="locked">{t("admin.common.locked")}</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")} />
) : (
<>
<UserManagementTable
users={data.items}
onEdit={handleEdit}
onToggleStatus={handleToggleStatus}
onCreate={handleCreate}
/>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
<UserFormModal
open={modalOpen}
user={editingUser}
roles={roles}
onSubmit={handleSubmit}
onCancel={() => setModalOpen(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,284 @@
"use client";
import { type ReactNode, useState } from "react";
import { DndContext, type DragEndEvent, closestCenter } from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ViewportConfigViewModel } from "@/types/view-models";
import { useViewports, useUpdateViewport } from "@/hooks/use-viewports";
import { useToast } from "@/providers/toast-provider";
import {
PageHeader,
PaperCard,
Button,
LoadingState,
ErrorState,
EmptyState,
Input,
Select,
} from "@/components/ui";
import { t } from "@/lib/i18n";
interface SortableViewportItemProps {
viewport: ViewportConfigViewModel;
onToggleVisible: (id: string, isVisible: boolean) => void;
onLabelChange: (id: string, label: string) => void;
onPermissionChange: (id: string, perm: string | null) => void;
}
function SortableViewportItem({
viewport,
onToggleVisible,
onLabelChange,
onPermissionChange,
}: SortableViewportItemProps): ReactNode {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: viewport.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
padding: "12px 16px",
borderBottom: "1px solid var(--color-rule)",
display: "grid",
gridTemplateColumns: "24px 120px 1fr 180px 80px 80px",
gap: 12,
alignItems: "center",
background: "var(--bg-paper)",
fontSize: 13,
};
return (
<div ref={setNodeRef} style={style}>
<button
{...attributes}
{...listeners}
aria-label="拖拽排序"
style={{
cursor: "grab",
background: "none",
border: "none",
color: "var(--color-ink-muted)",
}}
>
</button>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
{viewport.key}
</span>
<Input
type="text"
value={viewport.label}
onChange={(e) => onLabelChange(viewport.id, e.target.value)}
style={{ fontSize: 13 }}
/>
<Input
type="text"
value={viewport.requiredPermission ?? ""}
onChange={(e) =>
onPermissionChange(viewport.id, e.target.value || null)
}
placeholder="无需权限"
style={{ fontSize: 12, fontFamily: "var(--font-mono)" }}
/>
<span style={{ fontSize: 11, color: "var(--color-ink-muted)" }}>
{viewport.scope}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<input
type="checkbox"
checked={viewport.isVisible}
onChange={(e) => onToggleVisible(viewport.id, e.target.checked)}
aria-label="是否可见"
/>
</span>
</div>
);
}
export default function ViewportsPage(): ReactNode {
const { data: viewports, loading, error } = useViewports();
const { show } = useToast();
const [updateViewport] = useUpdateViewport();
const [localViewports, setLocalViewports] = useState<
ViewportConfigViewModel[] | null
>(null);
const [scopeFilter, setScopeFilter] = useState<string>("all");
// 同步远端数据
if (viewports.length > 0 && localViewports === null) {
setLocalViewports(viewports);
}
const filteredViewports = (localViewports ?? viewports).filter(
(v) => scopeFilter === "all" || v.scope === scopeFilter,
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
if (!localViewports) return;
const oldIndex = localViewports.findIndex((v) => v.id === active.id);
const newIndex = localViewports.findIndex((v) => v.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const reordered = arrayMove(localViewports, oldIndex, newIndex).map(
(v, i) => ({ ...v, sortOrder: i + 1 }),
);
setLocalViewports(reordered);
};
const handleToggleVisible = async (id: string, isVisible: boolean) => {
try {
await updateViewport(id, { isVisible });
show("success", "已更新");
setLocalViewports(
(prev) =>
prev?.map((v) => (v.id === id ? { ...v, isVisible } : v)) ?? null,
);
} catch (err) {
show(
"error",
"更新失败",
err instanceof Error ? err.message : String(err),
);
}
};
const handleLabelChange = async (id: string, label: string) => {
setLocalViewports(
(prev) => prev?.map((v) => (v.id === id ? { ...v, label } : v)) ?? null,
);
};
const handlePermissionChange = async (id: string, perm: string | null) => {
setLocalViewports(
(prev) =>
prev?.map((v) =>
v.id === id ? { ...v, requiredPermission: perm } : v,
) ?? null,
);
};
const handleSaveOrder = async () => {
if (!localViewports) return;
try {
for (const vp of localViewports) {
await updateViewport(vp.id, {
sortOrder: vp.sortOrder,
label: vp.label,
requiredPermission: vp.requiredPermission,
isVisible: vp.isVisible,
});
}
show("success", "全部已保存");
} 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.viewports.title")}
description="配置各端视口的路由、权限与排序"
actions={
<>
<Select
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value)}
>
<option value="all">{t("admin.common.all")}</option>
<option value="teacher"></option>
<option value="student"></option>
<option value="parent"></option>
<option value="admin"></option>
</Select>
<Button variant="primary" onClick={handleSaveOrder}>
{t("admin.common.save")}
</Button>
</>
}
/>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && (
<>
{filteredViewports.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<div
style={{
padding: "8px 16px",
borderBottom: "2px solid var(--color-rule)",
display: "grid",
gridTemplateColumns: "24px 120px 1fr 180px 80px 80px",
gap: 12,
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
<span></span>
<span>{t("admin.viewports.key")}</span>
<span>{t("admin.viewports.label")}</span>
<span>{t("admin.viewports.requiredPermission")}</span>
<span>{t("admin.viewports.scope")}</span>
<span>{t("admin.viewports.visible")}</span>
</div>
<DndContext
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredViewports.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
{filteredViewports.map((vp) => (
<SortableViewportItem
key={vp.id}
viewport={vp}
onToggleVisible={handleToggleVisible}
onLabelChange={handleLabelChange}
onPermissionChange={handlePermissionChange}
/>
))}
</SortableContext>
</DndContext>
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
marginTop: 12,
padding: "0 16px",
}}
>
{t("admin.viewports.dragHint")}
</p>
</>
)}
</>
)}
</PaperCard>
</div>
);
}