feat(admin-portal): 完整实现 admin-portal 管理端微前端
包含 src 全部实现、Dockerfile、配置文件等
This commit is contained in:
176
apps/admin-portal/src/components/admin-shell.tsx
Normal file
176
apps/admin-portal/src/components/admin-shell.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { ROUTE_PERMISSIONS } from "@/lib/permissions";
|
||||
|
||||
interface NavItem {
|
||||
route: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ route: "/admin/dashboard", label: t("admin.nav.dashboard") },
|
||||
{ route: "/admin/users", label: t("admin.nav.users") },
|
||||
{ route: "/admin/roles", label: t("admin.nav.roles") },
|
||||
{ route: "/admin/permissions", label: t("admin.nav.permissions") },
|
||||
{ route: "/admin/viewports", label: t("admin.nav.viewports") },
|
||||
{ route: "/admin/organization", label: t("admin.nav.organization") },
|
||||
{ route: "/admin/classes", label: t("admin.nav.classes") },
|
||||
{ route: "/admin/teachers", label: t("admin.nav.teachers") },
|
||||
{ route: "/admin/students", label: t("admin.nav.students") },
|
||||
{ route: "/admin/audit-logs", label: t("admin.nav.auditLogs") },
|
||||
{ route: "/admin/system", label: t("admin.nav.system") },
|
||||
];
|
||||
|
||||
export function AdminShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { user, isLoading, isAuthenticated, logout, hasPermission } = useAuth();
|
||||
const { show } = useToast();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--color-ink-muted)" }}>
|
||||
{t("admin.common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleNavItems = NAV_ITEMS.filter((item) => {
|
||||
const perm = ROUTE_PERMISSIONS[item.route];
|
||||
if (!perm) return true;
|
||||
return hasPermission(perm);
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
show("info", t("admin.auth.logout"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex"
|
||||
style={{ background: "var(--bg-paper)" }}
|
||||
>
|
||||
{/* 跳过导航链接(A11y WCAG 2.2 AA)*/}
|
||||
<a href="#main-content" className="skip-link">
|
||||
跳到主内容
|
||||
</a>
|
||||
|
||||
{/* 左侧栏:导航树 */}
|
||||
<aside
|
||||
className="w-56 flex-shrink-0 border-r relative flex flex-col"
|
||||
style={{
|
||||
borderColor: "var(--color-rule)",
|
||||
background: "var(--bg-paper)",
|
||||
}}
|
||||
aria-label="管理端导航"
|
||||
>
|
||||
<div className="px-6 py-6">
|
||||
<h1
|
||||
className="text-xl"
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
Edu
|
||||
</h1>
|
||||
<p
|
||||
className="text-xs mt-1"
|
||||
style={{ color: "var(--color-ink-muted)" }}
|
||||
>
|
||||
{t("admin.auth.welcome")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rule-thin mx-6" />
|
||||
|
||||
<nav className="mt-4 px-3 flex-1 overflow-y-auto" aria-label="主导航">
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{visibleNavItems.map((item) => {
|
||||
const active =
|
||||
pathname === item.route ||
|
||||
pathname.startsWith(item.route + "/");
|
||||
return (
|
||||
<li key={item.route}>
|
||||
<Link
|
||||
href={item.route}
|
||||
className="block px-3 py-2 text-sm transition-colors"
|
||||
style={{
|
||||
color: active
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-ink)",
|
||||
borderLeft: active
|
||||
? "2px solid var(--color-accent)"
|
||||
: "2px solid transparent",
|
||||
fontFamily: active
|
||||
? "var(--font-serif)"
|
||||
: "var(--font-sans)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* 底部:用户信息 + 登出 */}
|
||||
<div
|
||||
className="px-6 py-4 border-t"
|
||||
style={{ borderColor: "var(--color-rule)" }}
|
||||
>
|
||||
{user && (
|
||||
<div className="mb-2">
|
||||
<p className="text-sm" style={{ color: "var(--color-ink)" }}>
|
||||
{user.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--color-ink-muted)" }}
|
||||
>
|
||||
{user.roles.join(", ") || "无角色"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-xs uppercase tracking-wide hover:opacity-70"
|
||||
style={{
|
||||
color: "var(--color-ink-muted)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{t("admin.auth.logout")}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 中间:内容区(纸面) */}
|
||||
<main id="main-content" className="flex-1 overflow-auto" role="main">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
apps/admin-portal/src/components/msw-initializer.tsx
Normal file
24
apps/admin-portal/src/components/msw-initializer.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* MSW 初始化组件
|
||||
*
|
||||
* NEXT_PUBLIC_API_MOCKING=enabled 时启动 MSW worker
|
||||
* 上游就绪后设为 disabled,使用真实 API
|
||||
*/
|
||||
export function MswInitializer({ onReady }: { onReady: () => void }) {
|
||||
useEffect(() => {
|
||||
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
|
||||
import("@/mocks/browser")
|
||||
.then(({ worker }) => worker.start({ onUnhandledRequest: "bypass" }))
|
||||
.then(onReady)
|
||||
.catch(() => onReady());
|
||||
} else {
|
||||
onReady();
|
||||
}
|
||||
}, [onReady]);
|
||||
|
||||
return null;
|
||||
}
|
||||
164
apps/admin-portal/src/components/notification-panel.tsx
Normal file
164
apps/admin-portal/src/components/notification-panel.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { useWebSocket } from "@/hooks/use-websocket";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import { Badge } from "./ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { WsNotification } from "@/types/view-models";
|
||||
|
||||
const severityColor: Record<WsNotification["severity"], string> = {
|
||||
info: "var(--color-accent)",
|
||||
warning: "var(--color-warning)",
|
||||
error: "var(--color-danger)",
|
||||
};
|
||||
|
||||
const typeLabel: Record<WsNotification["type"], string> = {
|
||||
audit_alert: t("admin.notification.auditAlert"),
|
||||
abnormal_login: t("admin.notification.abnormalLogin"),
|
||||
system_error: t("admin.notification.systemError"),
|
||||
info: "系统通知",
|
||||
};
|
||||
|
||||
export function NotificationPanel(): ReactNode {
|
||||
const { notifications, connected, dismiss, clear } = useWebSocket(20);
|
||||
const { show } = useToast();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 320,
|
||||
maxHeight: "60vh",
|
||||
overflowY: "auto",
|
||||
background: "var(--bg-paper)",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.08)",
|
||||
zIndex: 100,
|
||||
}}
|
||||
role="region"
|
||||
aria-label="通知中心"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 16px",
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{t("admin.notification.title")}
|
||||
</h3>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Badge status={connected ? "healthy" : "down"} />
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={clear}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--color-ink-muted)",
|
||||
fontSize: 11,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{notifications.length === 0 && (
|
||||
<li
|
||||
style={{
|
||||
padding: "24px 16px",
|
||||
textAlign: "center",
|
||||
color: "var(--color-ink-muted)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
暂无通知
|
||||
</li>
|
||||
)}
|
||||
{notifications.map((notif) => (
|
||||
<li
|
||||
key={notif.id}
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
borderLeft: `3px solid ${severityColor[notif.severity]}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onClick={() => {
|
||||
show(
|
||||
notif.severity === "error"
|
||||
? "error"
|
||||
: notif.severity === "warning"
|
||||
? "warning"
|
||||
: "info",
|
||||
notif.title,
|
||||
notif.message,
|
||||
);
|
||||
dismiss(notif.id);
|
||||
}}
|
||||
aria-label={`${typeLabel[notif.type]}: ${notif.message}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--color-ink)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{typeLabel[notif.type]}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: "var(--color-ink-muted)" }}>
|
||||
{new Date(notif.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink-muted)",
|
||||
margin: "4px 0 0 0",
|
||||
}}
|
||||
>
|
||||
{notif.message}
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
apps/admin-portal/src/components/organization-tree.tsx
Normal file
188
apps/admin-portal/src/components/organization-tree.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { useOrganization } from "@/hooks/use-organization";
|
||||
import type { OrganizationNode } from "@/types/view-models";
|
||||
|
||||
interface OrganizationTreeProps {
|
||||
onSelect?: (node: OrganizationNode) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: OrganizationNode;
|
||||
level: number;
|
||||
onSelect?: (node: OrganizationNode) => void;
|
||||
}
|
||||
|
||||
const typeLabel: Record<OrganizationNode["type"], string> = {
|
||||
school: "学校",
|
||||
grade: "年级",
|
||||
class: "班级",
|
||||
};
|
||||
|
||||
function TreeNode({ node, level, onSelect }: TreeNodeProps): ReactNode {
|
||||
const [expanded, setExpanded] = useState(level < 2);
|
||||
const [children, setChildren] = useState<OrganizationNode[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [selected, setSelected] = useState(false);
|
||||
const { data, loading } = useOrganization(expanded ? node.id : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && !loaded && data.length > 0) {
|
||||
setChildren(data);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [expanded, loaded, data]);
|
||||
|
||||
const hasChildren = node.childrenCount > 0;
|
||||
void loading;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelected(true);
|
||||
onSelect?.(node);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
padding: "6px 12px",
|
||||
paddingLeft: 12 + level * 16,
|
||||
cursor: "pointer",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 13,
|
||||
background: selected ? "var(--color-accent-light)" : "transparent",
|
||||
border: "none",
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onClick={() => {
|
||||
setSelected(true);
|
||||
onSelect?.(node);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="treeitem"
|
||||
aria-expanded={hasChildren ? expanded : undefined}
|
||||
aria-selected={selected}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((v) => !v);
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--color-ink-muted)",
|
||||
padding: 0,
|
||||
marginRight: 4,
|
||||
fontSize: 10,
|
||||
userSelect: "none",
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setExpanded((v) => !v);
|
||||
}
|
||||
}}
|
||||
aria-label={expanded ? "折叠" : "展开"}
|
||||
>
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ display: "inline-block", width: 14 }} />
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontFamily:
|
||||
node.type === "school" ? "var(--font-serif)" : "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontSize: 10,
|
||||
color: "var(--color-ink-muted)",
|
||||
padding: "1px 6px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{typeLabel[node.type]}
|
||||
</span>
|
||||
{hasChildren && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
({node.childrenCount})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && hasChildren && (
|
||||
<div role="group">
|
||||
{children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrganizationTree({
|
||||
onSelect,
|
||||
}: OrganizationTreeProps): ReactNode {
|
||||
const { data: roots, loading, error } = useOrganization(null);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div
|
||||
style={{ padding: 16, color: "var(--color-ink-muted)", fontSize: 13 }}
|
||||
>
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
if (error)
|
||||
return (
|
||||
<div style={{ padding: 16, color: "var(--color-danger)", fontSize: 13 }}>
|
||||
{error.message}
|
||||
</div>
|
||||
);
|
||||
if (roots.length === 0)
|
||||
return (
|
||||
<div
|
||||
style={{ padding: 16, color: "var(--color-ink-muted)", fontSize: 13 }}
|
||||
>
|
||||
暂无数据
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div role="tree" aria-label="组织架构树">
|
||||
{roots.map((node) => (
|
||||
<TreeNode key={node.id} node={node} level={0} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
apps/admin-portal/src/components/role-permission-matrix.tsx
Normal file
147
apps/admin-portal/src/components/role-permission-matrix.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import type { RoleViewModel, PermissionViewModel } from "@/types/view-models";
|
||||
import { Table, TableRow, TableCell, Button, Badge } from "./ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface RolePermissionMatrixProps {
|
||||
role: RoleViewModel;
|
||||
permissions: PermissionViewModel[];
|
||||
onSave: (permissionCodes: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RolePermissionMatrix({
|
||||
role,
|
||||
permissions,
|
||||
onSave,
|
||||
}: RolePermissionMatrixProps): ReactNode {
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
new Set(role.permissions.map((p) => p.code)),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(new Set(role.permissions.map((p) => p.code)));
|
||||
}, [role]);
|
||||
|
||||
const toggle = (code: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(code)) {
|
||||
next.delete(code);
|
||||
} else {
|
||||
next.add(code);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleResource = (resource: string) => {
|
||||
const resourcePerms = permissions.filter((p) => p.resource === resource);
|
||||
const allSelected = resourcePerms.every((p) => selected.has(p.code));
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allSelected) {
|
||||
resourcePerms.forEach((p) => next.delete(p.code));
|
||||
} else {
|
||||
resourcePerms.forEach((p) => next.add(p.code));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(Array.from(selected));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 按资源分组
|
||||
const resourceGroups = permissions.reduce<
|
||||
Record<string, PermissionViewModel[]>
|
||||
>((acc, p) => {
|
||||
const arr = acc[p.resource] ?? (acc[p.resource] = []);
|
||||
arr.push(p);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{role.name}
|
||||
{role.isSystem && <Badge status="active" />}
|
||||
</h3>
|
||||
<p style={{ fontSize: 12, color: "var(--color-ink-muted)" }}>
|
||||
{role.code} · {t("admin.roles.userCount")}: {role.userCount} ·{" "}
|
||||
{role.dataScope}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? t("admin.common.loading") : t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table headers={["资源", "权限点", "操作", "说明", "授权"]}>
|
||||
{Object.entries(resourceGroups).map(([resource, perms]) =>
|
||||
perms.map((perm, idx) => (
|
||||
<TableRow key={perm.id}>
|
||||
{idx === 0 && (
|
||||
<TableCell
|
||||
rowSpan={perms.length}
|
||||
style={{ verticalAlign: "top", fontWeight: 500 }}
|
||||
>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: 6 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={perms.every((p) => selected.has(p.code))}
|
||||
ref={(el) => {
|
||||
if (el)
|
||||
el.indeterminate =
|
||||
perms.some((p) => selected.has(p.code)) &&
|
||||
!perms.every((p) => selected.has(p.code));
|
||||
}}
|
||||
onChange={() => toggleResource(resource)}
|
||||
/>
|
||||
{resource}
|
||||
</label>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
>
|
||||
{perm.code}
|
||||
</TableCell>
|
||||
<TableCell>{perm.action}</TableCell>
|
||||
<TableCell
|
||||
style={{ color: "var(--color-ink-muted)", fontSize: 12 }}
|
||||
>
|
||||
{perm.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(perm.code)}
|
||||
onChange={() => toggle(perm.code)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)),
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
375
apps/admin-portal/src/components/ui.tsx
Normal file
375
apps/admin-portal/src/components/ui.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* 通用 UI 组件(纸面风格)
|
||||
*
|
||||
* 复用 @edu/ui-components 中的基础组件,扩展 admin 专用组件
|
||||
*/
|
||||
import { type ReactNode, type ButtonHTMLAttributes } from "react";
|
||||
|
||||
/** 纸面卡片 */
|
||||
export function PaperCard({
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white border rounded-lg ${className}`}
|
||||
style={{
|
||||
borderColor: "var(--color-rule)",
|
||||
background: "var(--bg-paper)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 页面标题 */
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="flex items-start justify-between mb-6 pb-4"
|
||||
style={{ borderBottom: "1px solid var(--color-rule)" }}
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 24,
|
||||
color: "var(--color-ink)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-ink-muted)",
|
||||
fontSize: 13,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 按钮 */
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
const buttonStyles: Record<ButtonVariant, Record<string, string>> = {
|
||||
primary: {
|
||||
background: "var(--color-accent)",
|
||||
color: "var(--bg-paper)",
|
||||
border: "1px solid var(--color-accent)",
|
||||
},
|
||||
secondary: {
|
||||
background: "var(--bg-paper)",
|
||||
color: "var(--color-ink)",
|
||||
border: "1px solid var(--color-rule)",
|
||||
},
|
||||
ghost: {
|
||||
background: "transparent",
|
||||
color: "var(--color-ink-muted)",
|
||||
border: "1px solid transparent",
|
||||
},
|
||||
danger: {
|
||||
background: "var(--color-danger)",
|
||||
color: "var(--bg-paper)",
|
||||
border: "1px solid var(--color-danger)",
|
||||
},
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "secondary",
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: ButtonProps): ReactNode {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
style={{
|
||||
...buttonStyles[variant],
|
||||
padding: "6px 14px",
|
||||
fontSize: 13,
|
||||
borderRadius: 4,
|
||||
cursor: props.disabled ? "not-allowed" : "pointer",
|
||||
opacity: props.disabled ? 0.5 : 1,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 输入框 */
|
||||
export function Input({
|
||||
style,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement>): ReactNode {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
background: "var(--bg-paper)",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 13,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 选择框 */
|
||||
export function Select({
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.SelectHTMLAttributes<HTMLSelectElement>): ReactNode {
|
||||
return (
|
||||
<select
|
||||
{...props}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
background: "var(--bg-paper)",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 13,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/** 标签(状态徽章) */
|
||||
export function Badge({ status }: { status: string }): ReactNode {
|
||||
const colorMap: Record<string, string> = {
|
||||
active: "var(--color-success)",
|
||||
disabled: "var(--color-ink-muted)",
|
||||
locked: "var(--color-danger)",
|
||||
healthy: "var(--color-success)",
|
||||
degraded: "var(--color-warning)",
|
||||
down: "var(--color-danger)",
|
||||
};
|
||||
const labelMap: Record<string, string> = {
|
||||
active: "启用",
|
||||
disabled: "禁用",
|
||||
locked: "锁定",
|
||||
healthy: "健康",
|
||||
degraded: "降级",
|
||||
down: "异常",
|
||||
};
|
||||
const color = colorMap[status] ?? "var(--color-ink-muted)";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
fontSize: 11,
|
||||
borderRadius: 10,
|
||||
background: `${color}20`,
|
||||
color,
|
||||
border: `1px solid ${color}40`,
|
||||
}}
|
||||
>
|
||||
{labelMap[status] ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表格 */
|
||||
export function Table({
|
||||
headers,
|
||||
children,
|
||||
}: {
|
||||
headers: string[];
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="w-full"
|
||||
style={{ borderCollapse: "collapse", fontSize: 13 }}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "2px solid var(--color-rule)" }}>
|
||||
{headers.map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
color: "var(--color-ink-muted)",
|
||||
fontWeight: 500,
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
whiteSpace: i === 0 ? "nowrap" : "normal",
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表格行 */
|
||||
export function TableRow({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<tr style={{ borderBottom: "1px solid var(--color-rule)" }}>{children}</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表格单元格 */
|
||||
export function TableCell({
|
||||
children,
|
||||
style,
|
||||
rowSpan,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
rowSpan?: number;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<td
|
||||
style={{ padding: "10px 12px", color: "var(--color-ink)", ...style }}
|
||||
rowSpan={rowSpan}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/** 空状态 */
|
||||
export function EmptyState({ message }: { message: string }): ReactNode {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "48px 16px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 14 }}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 加载状态 */
|
||||
export function LoadingState(): ReactNode {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "48px 16px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 14 }}>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 错误状态 */
|
||||
export function ErrorState({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "48px 16px",
|
||||
color: "var(--color-danger)",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 14, marginBottom: 8 }}>{message}</p>
|
||||
{onRetry && (
|
||||
<Button variant="secondary" onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 分页 */
|
||||
export function Pagination({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
hasNext,
|
||||
onPageChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
hasNext: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
}): ReactNode {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between mt-4"
|
||||
style={{ fontSize: 12, color: "var(--color-ink-muted)" }}
|
||||
>
|
||||
<span>
|
||||
共 {total} 条,第 {page}/{totalPages || 1} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!hasNext}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
apps/admin-portal/src/components/user-form-modal.tsx
Normal file
216
apps/admin-portal/src/components/user-form-modal.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, type FormEvent } from "react";
|
||||
import type { UserViewModel, RoleViewModel } from "@/types/view-models";
|
||||
import { Button, Input, Select } from "./ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface UserFormModalProps {
|
||||
open: boolean;
|
||||
user: UserViewModel | null;
|
||||
roles: RoleViewModel[];
|
||||
onSubmit: (data: UserFormData) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export interface UserFormData {
|
||||
email: string;
|
||||
name: string;
|
||||
password?: string;
|
||||
roleIds: string[];
|
||||
dataScope: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export function UserFormModal({
|
||||
open,
|
||||
user,
|
||||
roles,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: UserFormModalProps): ReactNode {
|
||||
const [email, setEmail] = useState(user?.email ?? "");
|
||||
const [name, setName] = useState(user?.name ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [roleIds, setRoleIds] = useState<string[]>(
|
||||
user?.roles.map((r) => r.id) ?? [],
|
||||
);
|
||||
const [dataScope, setDataScope] = useState<string>(
|
||||
user?.dataScope ?? "SCHOOL",
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const isEdit = user !== null;
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
email,
|
||||
name,
|
||||
password: isEdit ? undefined : password,
|
||||
roleIds,
|
||||
dataScope,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRole = (id: string) => {
|
||||
setRoleIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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,
|
||||
maxHeight: "80vh",
|
||||
overflowY: "auto",
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{isEdit ? t("admin.users.edit") : t("admin.users.new")}
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
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.common.email")}
|
||||
</span>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.common.name")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{!isEdit && (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.users.password")}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.common.dataScope")}
|
||||
</span>
|
||||
<Select
|
||||
value={dataScope}
|
||||
onChange={(e) => setDataScope(e.target.value)}
|
||||
>
|
||||
<option value="ALL">ALL - 全局</option>
|
||||
<option value="SCHOOL">SCHOOL - 学校</option>
|
||||
<option value="GRADE">GRADE - 年级</option>
|
||||
<option value="CLASS">CLASS - 班级</option>
|
||||
<option value="SUBJECT">SUBJECT - 学科</option>
|
||||
<option value="SELF">SELF - 自己</option>
|
||||
</Select>
|
||||
</label>
|
||||
<fieldset
|
||||
style={{
|
||||
border: "1px solid var(--color-rule)",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<legend style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.users.roles")}
|
||||
</legend>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{roles.map((role) => (
|
||||
<label
|
||||
key={role.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={roleIds.includes(role.id)}
|
||||
onChange={() => toggleRole(role.id)}
|
||||
/>
|
||||
<span>{role.name}</span>
|
||||
<span
|
||||
style={{ color: "var(--color-ink-muted)", fontSize: 11 }}
|
||||
>
|
||||
({role.code})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" type="button" onClick={onCancel}>
|
||||
{t("admin.common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" disabled={loading}>
|
||||
{loading ? t("admin.common.loading") : t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/admin-portal/src/components/user-management-table.tsx
Normal file
72
apps/admin-portal/src/components/user-management-table.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import type { UserViewModel } from "@/types/view-models";
|
||||
import { Badge, Button, Table, TableRow, TableCell } from "./ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface UserManagementTableProps {
|
||||
users: UserViewModel[];
|
||||
onEdit: (user: UserViewModel) => void;
|
||||
onToggleStatus: (user: UserViewModel) => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
export function UserManagementTable({
|
||||
users,
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
onCreate,
|
||||
}: UserManagementTableProps): ReactNode {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button variant="primary" onClick={onCreate}>
|
||||
{t("admin.users.new")}
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
headers={[
|
||||
"邮箱",
|
||||
"姓名",
|
||||
"角色",
|
||||
"状态",
|
||||
"数据范围",
|
||||
"学校",
|
||||
"最后登录",
|
||||
"操作",
|
||||
]}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>
|
||||
{user.roles.map((r) => r.name).join(", ") || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={user.status} />
|
||||
</TableCell>
|
||||
<TableCell>{user.dataScope}</TableCell>
|
||||
<TableCell>{user.schoolName ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
{user.lastLoginAt
|
||||
? new Date(user.lastLoginAt).toLocaleDateString("zh-CN")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => onEdit(user)}>
|
||||
{t("admin.common.edit")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => onToggleStatus(user)}>
|
||||
{t("admin.users.toggleStatus")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/admin-portal/src/components/web-vitals-initializer.tsx
Normal file
20
apps/admin-portal/src/components/web-vitals-initializer.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Web Vitals 采集初始化组件
|
||||
*
|
||||
* 仲裁:所有前端应用必须采集 Web Vitals
|
||||
* 在客户端挂载时初始化 LCP/CLS/FCP/INP/TTFB 采集
|
||||
*/
|
||||
export function WebVitalsInitializer(): null {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
void import("@/lib/web-vitals").then(({ initWebVitals }) =>
|
||||
initWebVitals(),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user