feat(admin-portal): 完整实现 admin-portal 管理端微前端
包含 src 全部实现、Dockerfile、配置文件等
This commit is contained in:
89
apps/admin-portal/src/hooks/use-audit-logs.ts
Normal file
89
apps/admin-portal/src/hooks/use-audit-logs.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 审计日志 Hooks
|
||||
*
|
||||
* contract §2.4:auditLogs
|
||||
* 仲裁 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);
|
||||
}
|
||||
44
apps/admin-portal/src/hooks/use-classes.ts
Normal file
44
apps/admin-portal/src/hooks/use-classes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 班级管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminClasses
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
20
apps/admin-portal/src/hooks/use-dashboard.ts
Normal file
20
apps/admin-portal/src/hooks/use-dashboard.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 仪表盘 Hooks
|
||||
*
|
||||
* contract §2.4:adminDashboard
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
100
apps/admin-portal/src/hooks/use-graphql.ts
Normal file
100
apps/admin-portal/src/hooks/use-graphql.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 通用 GraphQL hooks
|
||||
*
|
||||
* 仲裁 ARB-001:admin 命名空间 GraphQL(urql)
|
||||
*
|
||||
* 注意: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];
|
||||
}
|
||||
24
apps/admin-portal/src/hooks/use-organization.ts
Normal file
24
apps/admin-portal/src/hooks/use-organization.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 组织管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminOrganization
|
||||
*/
|
||||
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 ?? [],
|
||||
};
|
||||
}
|
||||
22
apps/admin-portal/src/hooks/use-permissions.ts
Normal file
22
apps/admin-portal/src/hooks/use-permissions.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 权限点 Hooks
|
||||
*
|
||||
* contract §2.4:adminPermissions
|
||||
*/
|
||||
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 ?? [],
|
||||
};
|
||||
}
|
||||
73
apps/admin-portal/src/hooks/use-roles.ts
Normal file
73
apps/admin-portal/src/hooks/use-roles.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 角色权限 Hooks
|
||||
*
|
||||
* contract §2.4:adminRoles / 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;
|
||||
}
|
||||
48
apps/admin-portal/src/hooks/use-students.ts
Normal file
48
apps/admin-portal/src/hooks/use-students.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 学生管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminStudents
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
48
apps/admin-portal/src/hooks/use-system-settings.ts
Normal file
48
apps/admin-portal/src/hooks/use-system-settings.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 系统设置 Hooks
|
||||
*
|
||||
* contract §2.4:systemSettings / 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;
|
||||
}
|
||||
48
apps/admin-portal/src/hooks/use-teachers.ts
Normal file
48
apps/admin-portal/src/hooks/use-teachers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 教师管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminTeachers
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
138
apps/admin-portal/src/hooks/use-users.ts
Normal file
138
apps/admin-portal/src/hooks/use-users.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 用户管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminUsers / 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 };
|
||||
}
|
||||
59
apps/admin-portal/src/hooks/use-viewports.ts
Normal file
59
apps/admin-portal/src/hooks/use-viewports.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 视口配置 Hooks
|
||||
*
|
||||
* contract §2.4:adminViewports / 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;
|
||||
}
|
||||
125
apps/admin-portal/src/hooks/use-websocket.ts
Normal file
125
apps/admin-portal/src/hooks/use-websocket.ts
Normal 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(() => {
|
||||
// 开发期 mock:MSW 不拦截 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user