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,89 @@
/**
* 审计日志 Hooks
*
* contract §2.4auditLogs
* 仲裁 ARB-005经 teacher-bff GraphQL 消费(不直连 Kafka
*/
import { useCallback, useState } from "react";
import { useGraphQuery } from "./use-graphql";
import { AUDIT_LOGS_QUERY } from "@/lib/graphql-client";
import type {
AuditLogViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface AuditLogsResponse {
auditLogs: PaginatedResult<AuditLogViewModel>;
}
interface AuditLogFilter extends Partial<ListFilter> {
action?: string;
actorUserId?: string;
startDate?: number;
endDate?: number;
}
export function useAuditLogs(filter: AuditLogFilter) {
const result = useGraphQuery<AuditLogsResponse>(AUDIT_LOGS_QUERY, { filter });
return {
...result,
data: result.data?.auditLogs ?? null,
};
}
export function useAuditLogFilter(initial?: Partial<AuditLogFilter>) {
const [filter, setFilter] = useState<AuditLogFilter>({
page: 1,
pageSize: 20,
...initial,
});
const setPage = useCallback(
(page: number) => setFilter((f) => ({ ...f, page })),
[],
);
const setSearch = useCallback(
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
[],
);
const setAction = useCallback(
(action: string) =>
setFilter((f) => ({ ...f, action: action || undefined, page: 1 })),
[],
);
return { filter, setFilter, setPage, setSearch, setAction };
}
/** 审计日志导出 CSV */
export function exportAuditLogsCsv(logs: AuditLogViewModel[]): void {
const headers = [
"时间",
"操作人",
"操作",
"资源类型",
"资源ID",
"IP",
"追踪ID",
];
const rows = logs.map((l) => [
new Date(l.occurredAt).toLocaleString("zh-CN"),
l.actorName,
l.action,
l.resourceType,
l.resourceId,
l.ip,
l.traceId ?? "",
]);
const csv = [headers, ...rows]
.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
)
.join("\n");
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,44 @@
/**
* 班级管理 Hooks
*
* contract §2.4adminClasses
*/
import { useCallback, useState } from "react";
import { useGraphQuery } from "./use-graphql";
import { ADMIN_CLASSES_QUERY } from "@/lib/graphql-client";
import type {
AdminClassViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface AdminClassesResponse {
adminClasses: PaginatedResult<AdminClassViewModel>;
}
export function useClasses(filter: Partial<ListFilter>) {
const result = useGraphQuery<AdminClassesResponse>(ADMIN_CLASSES_QUERY, {
filter,
});
return {
...result,
data: result.data?.adminClasses ?? null,
};
}
export function useClassFilter(initial?: Partial<ListFilter>) {
const [filter, setFilter] = useState<Partial<ListFilter>>({
page: 1,
pageSize: 20,
...initial,
});
const setPage = useCallback(
(page: number) => setFilter((f) => ({ ...f, page })),
[],
);
const setSearch = useCallback(
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
[],
);
return { filter, setFilter, setPage, setSearch };
}

View File

@@ -0,0 +1,20 @@
/**
* 仪表盘 Hooks
*
* contract §2.4adminDashboard
*/
import { useGraphQuery } from "./use-graphql";
import { ADMIN_DASHBOARD_QUERY } from "@/lib/graphql-client";
import type { AdminDashboardViewModel } from "@/types/view-models";
interface AdminDashboardResponse {
adminDashboard: AdminDashboardViewModel;
}
export function useDashboard() {
const result = useGraphQuery<AdminDashboardResponse>(ADMIN_DASHBOARD_QUERY);
return {
...result,
data: result.data?.adminDashboard ?? null,
};
}

View File

@@ -0,0 +1,100 @@
/**
* 通用 GraphQL hooks
*
* 仲裁 ARB-001admin 命名空间 GraphQLurql
*
* 注意urql 的 useQuery/useMutation 的泛型推断与纯字符串 query 兼容性有限,
* 这里使用类型断言从 unknown 转换(符合项目规则:从 unknown 转换允许 as 断言)
*/
import { useQuery, useMutation } from "urql";
import { useCallback, useState } from "react";
import type { AnyVariables, OperationResult } from "@urql/core";
export interface QueryResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
export interface MutationResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
/** 通用 Query Hook */
export function useGraphQuery<
T = unknown,
V extends AnyVariables = AnyVariables,
>(query: string, variables?: V): QueryResult<T> {
const [result] = useQuery({
query,
variables: variables ?? ({} as V),
});
return {
data: (result.data as T | undefined) ?? null,
loading: result.fetching,
error: result.error ? new Error(result.error.message) : null,
refetch: () => {
void result;
},
};
}
/** 通用 Mutation Hook */
export function useGraphMutation<
T = unknown,
V extends AnyVariables = AnyVariables,
>(
mutation: string,
): [(variables: V) => Promise<MutationResult<T>>, MutationResult<T>] {
// urql 的 useMutation 返回 [executeFn, state]
// 使用 unknown 转换处理类型(项目规则允许从 unknown 转换)
const [executeMutation] = useMutation(mutation) as unknown as [
(variables: V) => { toPromise: () => Promise<OperationResult<T, V>> },
unknown,
];
const [state, setState] = useState<MutationResult<T>>({
data: null,
loading: false,
error: null,
});
const run = useCallback(
async (variables: V): Promise<MutationResult<T>> => {
setState({ data: null, loading: true, error: null });
try {
const res = await executeMutation(variables).toPromise();
if (res.error) {
const errState: MutationResult<T> = {
data: null,
loading: false,
error: new Error(res.error.message),
};
setState(errState);
return errState;
}
const okState: MutationResult<T> = {
data: res.data ?? null,
loading: false,
error: null,
};
setState(okState);
return okState;
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
const errState: MutationResult<T> = {
data: null,
loading: false,
error: err,
};
setState(errState);
return errState;
}
},
[executeMutation],
);
return [run, state];
}

View File

@@ -0,0 +1,24 @@
/**
* 组织管理 Hooks
*
* contract §2.4adminOrganization
*/
import { useGraphQuery } from "./use-graphql";
import { ADMIN_ORGANIZATION_QUERY } from "@/lib/graphql-client";
import type { OrganizationNode } from "@/types/view-models";
interface AdminOrganizationResponse {
adminOrganization: OrganizationNode[];
}
export function useOrganization(parentId: string | null | undefined) {
const variables = parentId === undefined ? {} : { parentId };
const result = useGraphQuery<AdminOrganizationResponse>(
ADMIN_ORGANIZATION_QUERY,
variables,
);
return {
...result,
data: result.data?.adminOrganization ?? [],
};
}

View File

@@ -0,0 +1,22 @@
/**
* 权限点 Hooks
*
* contract §2.4adminPermissions
*/
import { useGraphQuery } from "./use-graphql";
import { ADMIN_PERMISSIONS_QUERY } from "@/lib/graphql-client";
import type { PermissionViewModel } from "@/types/view-models";
interface AdminPermissionsResponse {
adminPermissions: PermissionViewModel[];
}
export function usePermissions() {
const result = useGraphQuery<AdminPermissionsResponse>(
ADMIN_PERMISSIONS_QUERY,
);
return {
...result,
data: result.data?.adminPermissions ?? [],
};
}

View File

@@ -0,0 +1,73 @@
/**
* 角色权限 Hooks
*
* contract §2.4adminRoles / createRole / updateRolePermissions
*/
import { useCallback } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
ADMIN_ROLES_QUERY,
CREATE_ROLE_MUTATION,
UPDATE_ROLE_PERMISSIONS_MUTATION,
} from "@/lib/graphql-client";
import type { RoleViewModel } from "@/types/view-models";
interface AdminRolesResponse {
adminRoles: RoleViewModel[];
}
interface CreateRoleResponse {
createRole: { id: string; name: string; code: string };
}
interface UpdateRolePermissionsResponse {
updateRolePermissions: {
id: string;
permissions: { id: string; code: string }[];
};
}
interface CreateRoleInput {
name: string;
code: string;
description?: string;
dataScope?: string;
}
export function useRoles() {
const result = useGraphQuery<AdminRolesResponse>(ADMIN_ROLES_QUERY);
return {
...result,
data: result.data?.adminRoles ?? [],
};
}
export function useCreateRole() {
const [run, state] = useGraphMutation<
CreateRoleResponse,
{ input: CreateRoleInput }
>(CREATE_ROLE_MUTATION);
const create = useCallback(
async (input: CreateRoleInput) => {
const res = await run({ input });
return res.data?.createRole ?? null;
},
[run],
);
return [create, state] as const;
}
export function useUpdateRolePermissions() {
const [run, state] = useGraphMutation<
UpdateRolePermissionsResponse,
{ roleId: string; permissionCodes: string[] }
>(UPDATE_ROLE_PERMISSIONS_MUTATION);
const update = useCallback(
async (roleId: string, permissionCodes: string[]) => {
const res = await run({ roleId, permissionCodes });
return res.data?.updateRolePermissions ?? null;
},
[run],
);
return [update, state] as const;
}

View File

@@ -0,0 +1,48 @@
/**
* 学生管理 Hooks
*
* contract §2.4adminStudents
*/
import { useCallback, useState } from "react";
import { useGraphQuery } from "./use-graphql";
import { ADMIN_STUDENTS_QUERY } from "@/lib/graphql-client";
import type {
AdminStudentViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface AdminStudentsResponse {
adminStudents: PaginatedResult<AdminStudentViewModel>;
}
export function useStudents(filter: Partial<ListFilter>) {
const result = useGraphQuery<AdminStudentsResponse>(ADMIN_STUDENTS_QUERY, {
filter,
});
return {
...result,
data: result.data?.adminStudents ?? null,
};
}
export function useStudentFilter(initial?: Partial<ListFilter>) {
const [filter, setFilter] = useState<Partial<ListFilter>>({
page: 1,
pageSize: 20,
...initial,
});
const setPage = useCallback(
(page: number) => setFilter((f) => ({ ...f, page })),
[],
);
const setSearch = useCallback(
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
[],
);
const setStatus = useCallback(
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
[],
);
return { filter, setFilter, setPage, setSearch, setStatus };
}

View File

@@ -0,0 +1,48 @@
/**
* 系统设置 Hooks
*
* contract §2.4systemSettings / updateSystemSettings
*/
import { useCallback } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
SYSTEM_SETTINGS_QUERY,
UPDATE_SYSTEM_SETTINGS_MUTATION,
} from "@/lib/graphql-client";
import type { SystemSettingsViewModel } from "@/types/view-models";
interface SystemSettingsResponse {
systemSettings: SystemSettingsViewModel;
}
interface UpdateSystemSettingsResponse {
updateSystemSettings: {
schoolName: string;
schoolCode: string;
academicYear: string;
semester: string;
};
}
export function useSystemSettings() {
const result = useGraphQuery<SystemSettingsResponse>(SYSTEM_SETTINGS_QUERY);
return {
...result,
data: result.data?.systemSettings ?? null,
};
}
export function useUpdateSystemSettings() {
const [run, state] = useGraphMutation<
UpdateSystemSettingsResponse,
{ input: Partial<SystemSettingsViewModel> }
>(UPDATE_SYSTEM_SETTINGS_MUTATION);
const update = useCallback(
async (input: Partial<SystemSettingsViewModel>) => {
const res = await run({ input });
return res.data?.updateSystemSettings ?? null;
},
[run],
);
return [update, state] as const;
}

View File

@@ -0,0 +1,48 @@
/**
* 教师管理 Hooks
*
* contract §2.4adminTeachers
*/
import { useCallback, useState } from "react";
import { useGraphQuery } from "./use-graphql";
import { ADMIN_TEACHERS_QUERY } from "@/lib/graphql-client";
import type {
AdminTeacherViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface AdminTeachersResponse {
adminTeachers: PaginatedResult<AdminTeacherViewModel>;
}
export function useTeachers(filter: Partial<ListFilter>) {
const result = useGraphQuery<AdminTeachersResponse>(ADMIN_TEACHERS_QUERY, {
filter,
});
return {
...result,
data: result.data?.adminTeachers ?? null,
};
}
export function useTeacherFilter(initial?: Partial<ListFilter>) {
const [filter, setFilter] = useState<Partial<ListFilter>>({
page: 1,
pageSize: 20,
...initial,
});
const setPage = useCallback(
(page: number) => setFilter((f) => ({ ...f, page })),
[],
);
const setSearch = useCallback(
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
[],
);
const setStatus = useCallback(
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
[],
);
return { filter, setFilter, setPage, setSearch, setStatus };
}

View File

@@ -0,0 +1,138 @@
/**
* 用户管理 Hooks
*
* contract §2.4adminUsers / adminUser / createUser / updateUser / toggleUserStatus
*/
import { useCallback, useState } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
ADMIN_USERS_QUERY,
ADMIN_USER_QUERY,
CREATE_USER_MUTATION,
UPDATE_USER_MUTATION,
TOGGLE_USER_STATUS_MUTATION,
} from "@/lib/graphql-client";
import type {
UserViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface AdminUsersResponse {
adminUsers: PaginatedResult<UserViewModel>;
}
interface AdminUserResponse {
adminUser: UserViewModel | null;
}
interface UserMutationResponse {
createUser?: { id: string; email: string; name: string; status: string };
updateUser?: { id: string; email: string; name: string; status: string };
toggleUserStatus?: { id: string; status: string };
}
interface CreateUserInput {
email: string;
name: string;
password: string;
roleIds?: string[];
dataScope?: string;
organizationId?: string;
}
interface UpdateUserInput {
name?: string;
email?: string;
roleIds?: string[];
dataScope?: string;
organizationId?: string;
}
export function useUsers(filter: Partial<ListFilter>) {
const result = useGraphQuery<AdminUsersResponse>(ADMIN_USERS_QUERY, {
filter,
});
return {
...result,
data: result.data?.adminUsers ?? null,
};
}
export function useUser(id: string | null) {
const result = useGraphQuery<AdminUserResponse>(
ADMIN_USER_QUERY,
id ? { id } : undefined,
);
return {
...result,
data: result.data?.adminUser ?? null,
};
}
export function useCreateUser() {
const [run, state] = useGraphMutation<
UserMutationResponse,
{ input: CreateUserInput }
>(CREATE_USER_MUTATION);
const create = useCallback(
async (input: CreateUserInput) => {
const res = await run({ input });
return res.data?.createUser ?? null;
},
[run],
);
return [create, state] as const;
}
export function useUpdateUser() {
const [run, state] = useGraphMutation<
UserMutationResponse,
{ id: string; input: UpdateUserInput }
>(UPDATE_USER_MUTATION);
const update = useCallback(
async (id: string, input: UpdateUserInput) => {
const res = await run({ id, input });
return res.data?.updateUser ?? null;
},
[run],
);
return [update, state] as const;
}
export function useToggleUserStatus() {
const [run, state] = useGraphMutation<
UserMutationResponse,
{ id: string; status: string }
>(TOGGLE_USER_STATUS_MUTATION);
const toggle = useCallback(
async (id: string, status: string) => {
const res = await run({ id, status });
return res.data?.toggleUserStatus ?? null;
},
[run],
);
return [toggle, state] as const;
}
/** 本地筛选状态管理 */
export function useUserFilter(initial?: Partial<ListFilter>) {
const [filter, setFilter] = useState<Partial<ListFilter>>({
page: 1,
pageSize: 20,
...initial,
});
const setPage = useCallback(
(page: number) => setFilter((f) => ({ ...f, page })),
[],
);
const setSearch = useCallback(
(search: string) => setFilter((f) => ({ ...f, search, page: 1 })),
[],
);
const setStatus = useCallback(
(status: string) => setFilter((f) => ({ ...f, status, page: 1 })),
[],
);
return { filter, setFilter, setPage, setSearch, setStatus };
}

View File

@@ -0,0 +1,59 @@
/**
* 视口配置 Hooks
*
* contract §2.4adminViewports / updateViewport
*/
import { useCallback } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
ADMIN_VIEWPORTS_QUERY,
UPDATE_VIEWPORT_MUTATION,
} from "@/lib/graphql-client";
import type { ViewportConfigViewModel } from "@/types/view-models";
interface AdminViewportsResponse {
adminViewports: ViewportConfigViewModel[];
}
interface UpdateViewportResponse {
updateViewport: {
id: string;
key: string;
label: string;
sortOrder: number;
isVisible: boolean;
};
}
interface UpdateViewportInput {
label?: string;
sortOrder?: number;
isVisible?: boolean;
requiredPermission?: string | null;
}
export function useViewports(scope?: string) {
const result = useGraphQuery<AdminViewportsResponse>(
ADMIN_VIEWPORTS_QUERY,
scope ? { scope } : undefined,
);
return {
...result,
data: result.data?.adminViewports ?? [],
};
}
export function useUpdateViewport() {
const [run, state] = useGraphMutation<
UpdateViewportResponse,
{ id: string; input: UpdateViewportInput }
>(UPDATE_VIEWPORT_MUTATION);
const update = useCallback(
async (id: string, input: UpdateViewportInput) => {
const res = await run({ id, input });
return res.data?.updateViewport ?? null;
},
[run],
);
return [update, state] as const;
}

View File

@@ -0,0 +1,125 @@
/**
* WebSocket Hooks实时通知
*
* 仲裁 ARB-006接入 push-gateway GET /ws
* 开发期mock-socket 模拟 push-gateway
*
* 通知类型contract §3
* - audit_alert审计告警
* - abnormal_login异常登录
* - system_error系统异常
*/
import { useEffect, useRef, useState, useCallback } from "react";
import type { WsNotification } from "@/types/view-models";
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8081/ws";
/** 通知消息工厂 */
function createMockNotification(): WsNotification {
const types: WsNotification["type"][] = [
"audit_alert",
"abnormal_login",
"system_error",
"info",
];
const type = types[Math.floor(Math.random() * types.length)] ?? "info";
const now = Date.now();
const map: Record<
WsNotification["type"],
{ title: string; message: string; severity: WsNotification["severity"] }
> = {
audit_alert: {
title: "审计告警",
message: "检测到批量删除操作,请及时确认",
severity: "warning",
},
abnormal_login: {
title: "异常登录",
message: "检测到来自异常 IP 的登录尝试,已自动拦截",
severity: "error",
},
system_error: {
title: "系统异常",
message: "msg 服务响应超时(>280ms",
severity: "error",
},
info: {
title: "系统通知",
message: "数据备份已完成",
severity: "info",
},
};
const payload = map[type];
return {
id: `n-${now}-${Math.random().toString(36).slice(2, 6)}`,
type,
title: payload.title,
message: payload.message,
severity: payload.severity,
timestamp: now,
};
}
export function useWebSocket(maxNotifications = 50) {
const [notifications, setNotifications] = useState<WsNotification[]>([]);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const mockTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// 开发期 mockMSW 不拦截 WebSocket用 mock-socket 或定时推送
const isMock = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
if (isMock) {
// Mock 模式:定时推送通知
setConnected(true);
mockTimerRef.current = setInterval(() => {
const notif = createMockNotification();
setNotifications((prev) => [notif, ...prev].slice(0, maxNotifications));
}, 30000); // 每 30 秒推送一条
// 立即推送一条
const initial = createMockNotification();
setNotifications([initial]);
return () => {
if (mockTimerRef.current) clearInterval(mockTimerRef.current);
setConnected(false);
};
}
// 真实模式:连接 push-gateway
try {
const ws = new WebSocket(WS_URL);
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false);
ws.onerror = () => setConnected(false);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string) as WsNotification;
setNotifications((prev) =>
[data, ...prev].slice(0, maxNotifications),
);
} catch {
// 忽略非 JSON 消息
}
};
return () => {
ws.close();
setConnected(false);
};
} catch {
setConnected(false);
return;
}
}, [maxNotifications]);
const dismiss = useCallback((id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, []);
const clear = useCallback(() => setNotifications([]), []);
return { notifications, connected, dismiss, clear };
}