feat(admin-portal): 完成参考项目差距闭环 - 4 批次补齐 + Vitest 测试

差距分析闭环(workline §7-§8):
- P0:审计子模块 3 页 + 学校组织 7 页
- P1:系统设置 4 Card + 公告管理 + 邀请码管理
- P2:文件管理 + AI 配置
- P6:5 测试文件 / 69 用例全过

共享基础设施:
- permissions +11 权限点 +14 路由
- view-models +20 接口
- i18n +100 key
- graphql-client +25 operation
- fixtures/handlers +15 mock +20 handler

质量:typecheck + lint 零错误,vitest 69/69 通过,arch.db 已更新
This commit is contained in:
SpecialX
2026-07-13 13:05:29 +08:00
parent fad6e43b47
commit 7cf9aec20e
40 changed files with 6707 additions and 7 deletions

View File

@@ -0,0 +1,59 @@
/**
* AI 配置 Hooks
*
* contract §2.4aiProviders / updateAiProvider / aiUsage
*/
import { useCallback } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
AI_PROVIDERS_QUERY,
UPDATE_AI_PROVIDER_MUTATION,
AI_USAGE_QUERY,
} from "@/lib/graphql-client";
import type {
AiProviderViewModel,
AiUsageViewModel,
} from "@/types/view-models";
interface AiProvidersResponse {
aiProviders: AiProviderViewModel[];
}
interface UpdateAiProviderResponse {
updateAiProvider: { id: string; isActive: boolean };
}
interface AiUsageResponse {
aiUsage: AiUsageViewModel;
}
export function useAiProviders() {
const result = useGraphQuery<AiProvidersResponse>(AI_PROVIDERS_QUERY);
return {
...result,
data: result.data?.aiProviders ?? null,
};
}
export function useUpdateAiProvider() {
const [run, state] = useGraphMutation<
UpdateAiProviderResponse,
{ id: string; input: { isActive: boolean } }
>(UPDATE_AI_PROVIDER_MUTATION);
const update = useCallback(
async (id: string, input: { isActive: boolean }) => {
const res = await run({ id, input });
return res.data?.updateAiProvider ?? null;
},
[run],
);
return [update, state] as const;
}
export function useAiUsage() {
const result = useGraphQuery<AiUsageResponse>(AI_USAGE_QUERY);
return {
...result,
data: result.data?.aiUsage ?? null,
};
}

View File

@@ -0,0 +1,128 @@
/**
* 公告管理 Hooks
*
* contract §2.4adminAnnouncements / createAnnouncement / publishAnnouncement / archiveAnnouncement / toggleAnnouncementPin
*/
import { useCallback, useState } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
ADMIN_ANNOUNCEMENTS_QUERY,
CREATE_ANNOUNCEMENT_MUTATION,
PUBLISH_ANNOUNCEMENT_MUTATION,
ARCHIVE_ANNOUNCEMENT_MUTATION,
TOGGLE_ANNOUNCEMENT_PIN_MUTATION,
} from "@/lib/graphql-client";
import type {
AnnouncementViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface AdminAnnouncementsResponse {
adminAnnouncements: PaginatedResult<AnnouncementViewModel>;
}
interface AnnouncementMutationResponse {
createAnnouncement?: { id: string; title: string; status: string };
publishAnnouncement?: { id: string; status: string };
archiveAnnouncement?: { id: string; status: string };
toggleAnnouncementPin?: { id: string; isPinned: boolean };
}
interface CreateAnnouncementInput {
title: string;
content: string;
audience?: string[];
}
export function useAnnouncements(filter: Partial<ListFilter>) {
const result = useGraphQuery<AdminAnnouncementsResponse>(
ADMIN_ANNOUNCEMENTS_QUERY,
{ filter },
);
return {
...result,
data: result.data?.adminAnnouncements ?? null,
};
}
export function useAnnouncementFilter(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 };
}
export function useCreateAnnouncement() {
const [run, state] = useGraphMutation<
AnnouncementMutationResponse,
{ input: CreateAnnouncementInput }
>(CREATE_ANNOUNCEMENT_MUTATION);
const create = useCallback(
async (input: CreateAnnouncementInput) => {
const res = await run({ input });
return res.data?.createAnnouncement ?? null;
},
[run],
);
return [create, state] as const;
}
export function usePublishAnnouncement() {
const [run, state] = useGraphMutation<
AnnouncementMutationResponse,
{ id: string }
>(PUBLISH_ANNOUNCEMENT_MUTATION);
const publish = useCallback(
async (id: string) => {
const res = await run({ id });
return res.data?.publishAnnouncement ?? null;
},
[run],
);
return [publish, state] as const;
}
export function useArchiveAnnouncement() {
const [run, state] = useGraphMutation<
AnnouncementMutationResponse,
{ id: string }
>(ARCHIVE_ANNOUNCEMENT_MUTATION);
const archive = useCallback(
async (id: string) => {
const res = await run({ id });
return res.data?.archiveAnnouncement ?? null;
},
[run],
);
return [archive, state] as const;
}
export function useToggleAnnouncementPin() {
const [run, state] = useGraphMutation<
AnnouncementMutationResponse,
{ id: string }
>(TOGGLE_ANNOUNCEMENT_PIN_MUTATION);
const toggle = useCallback(
async (id: string) => {
const res = await run({ id });
return res.data?.toggleAnnouncementPin ?? null;
},
[run],
);
return [toggle, state] as const;
}

View File

@@ -0,0 +1,39 @@
/**
* 审计总览 Hooks
*
* contract §2.4auditOverviewStats / auditTrend
*/
import { useGraphQuery } from "./use-graphql";
import {
AUDIT_OVERVIEW_STATS_QUERY,
AUDIT_TREND_QUERY,
} from "@/lib/graphql-client";
import type { AuditOverviewStats, AuditTrendPoint } from "@/types/view-models";
interface AuditOverviewStatsResponse {
auditOverviewStats: AuditOverviewStats;
}
interface AuditTrendResponse {
auditTrend: AuditTrendPoint[];
}
export function useAuditOverviewStats() {
const result = useGraphQuery<AuditOverviewStatsResponse>(
AUDIT_OVERVIEW_STATS_QUERY,
);
return {
...result,
data: result.data?.auditOverviewStats ?? null,
};
}
export function useAuditTrend(days?: number) {
const result = useGraphQuery<AuditTrendResponse>(AUDIT_TREND_QUERY, {
days,
});
return {
...result,
data: result.data?.auditTrend ?? null,
};
}

View File

@@ -0,0 +1,97 @@
/**
* 数据变更日志 Hooks
*
* contract §2.4dataChangeLogs
*/
import { useCallback, useState } from "react";
import { useGraphQuery } from "./use-graphql";
import { DATA_CHANGE_LOGS_QUERY } from "@/lib/graphql-client";
import type {
DataChangeLogViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface DataChangeLogsResponse {
dataChangeLogs: PaginatedResult<DataChangeLogViewModel>;
}
interface DataChangeLogFilter extends Partial<ListFilter> {
tableName?: string;
action?: string;
startDate?: number;
endDate?: number;
}
export function useDataChangeLogs(filter: DataChangeLogFilter) {
const result = useGraphQuery<DataChangeLogsResponse>(DATA_CHANGE_LOGS_QUERY, {
filter,
});
return {
...result,
data: result.data?.dataChangeLogs ?? null,
};
}
export function useDataChangeLogFilter(initial?: Partial<DataChangeLogFilter>) {
const [filter, setFilter] = useState<DataChangeLogFilter>({
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 setTableName = useCallback(
(tableName: string) =>
setFilter((f) => ({ ...f, tableName: tableName || undefined, page: 1 })),
[],
);
const setAction = useCallback(
(action: string) =>
setFilter((f) => ({ ...f, action: action || undefined, page: 1 })),
[],
);
return { filter, setFilter, setPage, setSearch, setTableName, setAction };
}
/** 数据变更日志导出 CSV */
export function exportDataChangeLogsCsv(logs: DataChangeLogViewModel[]): void {
const headers = [
"时间",
"操作人",
"数据表",
"操作",
"记录ID",
"变更前",
"变更后",
"IP",
];
const rows = logs.map((l) => [
new Date(l.occurredAt).toLocaleString("zh-CN"),
l.actorName,
l.tableName,
l.action,
l.recordId,
l.beforeState ?? "",
l.afterState ?? "",
l.ip,
]);
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 = `data-change-logs-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,102 @@
/**
* 文件管理 Hooks
*
* contract §2.4adminFiles / adminFileStats / deleteFiles
*/
import { useCallback, useState } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
ADMIN_FILES_QUERY,
ADMIN_FILE_STATS_QUERY,
DELETE_FILES_MUTATION,
} from "@/lib/graphql-client";
import type {
FileViewModel,
FileStats,
PaginatedResult,
} from "@/types/view-models";
/** 文件列表筛选参数 */
export interface FileFilter {
page?: number;
pageSize?: number;
search?: string;
mimeType?: string;
entityType?: string;
}
interface AdminFilesResponse {
adminFiles: PaginatedResult<FileViewModel>;
}
interface AdminFileStatsResponse {
adminFileStats: FileStats;
}
interface DeleteFilesResponse {
deleteFiles: boolean;
}
export function useFiles(filter: FileFilter) {
const result = useGraphQuery<AdminFilesResponse>(ADMIN_FILES_QUERY, {
filter,
});
return {
...result,
data: result.data?.adminFiles ?? null,
};
}
export function useFileStats() {
const result = useGraphQuery<AdminFileStatsResponse>(ADMIN_FILE_STATS_QUERY);
return {
...result,
data: result.data?.adminFileStats ?? null,
};
}
export function useDeleteFiles() {
const [run, state] = useGraphMutation<DeleteFilesResponse, { ids: string[] }>(
DELETE_FILES_MUTATION,
);
const deleteFiles = useCallback(
async (ids: string[]) => {
const res = await run({ ids });
return res.data?.deleteFiles ?? false;
},
[run],
);
return [deleteFiles, state] as const;
}
/** 文件筛选状态管理 */
export function useFileFilter(initial?: FileFilter) {
const [filter, setFilter] = useState<FileFilter>({
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 setMimeType = useCallback(
(mimeType: string) =>
setFilter((f) => ({ ...f, mimeType: mimeType || undefined, page: 1 })),
[],
);
const setEntityType = useCallback(
(entityType: string) =>
setFilter((f) => ({
...f,
entityType: entityType || undefined,
page: 1,
})),
[],
);
return { filter, setFilter, setPage, setSearch, setMimeType, setEntityType };
}

View File

@@ -0,0 +1,99 @@
/**
* 邀请码管理 Hooks
*
* contract §2.4adminInvitationCodes / generateInvitationCodes / deleteInvitationCodes
*/
import { useCallback, useState } from "react";
import { useGraphMutation, useGraphQuery } from "./use-graphql";
import {
ADMIN_INVITATION_CODES_QUERY,
GENERATE_INVITATION_CODES_MUTATION,
DELETE_INVITATION_CODES_MUTATION,
} from "@/lib/graphql-client";
import type {
InvitationCodeViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface AdminInvitationCodesResponse {
adminInvitationCodes: PaginatedResult<InvitationCodeViewModel>;
}
interface GenerateInvitationCodesResponse {
generateInvitationCodes: { codes: string[]; batchId: string };
}
interface DeleteInvitationCodesResponse {
deleteInvitationCodes: boolean;
}
interface GenerateInvitationCodesInput {
count: number;
roleId?: string;
classId?: string;
email?: string;
expiresAt?: number;
}
export function useInvitationCodes(filter: Partial<ListFilter>) {
const result = useGraphQuery<AdminInvitationCodesResponse>(
ADMIN_INVITATION_CODES_QUERY,
{ filter },
);
return {
...result,
data: result.data?.adminInvitationCodes ?? null,
};
}
export function useInvitationCodeFilter(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 };
}
export function useGenerateInvitationCodes() {
const [run, state] = useGraphMutation<
GenerateInvitationCodesResponse,
{ input: GenerateInvitationCodesInput }
>(GENERATE_INVITATION_CODES_MUTATION);
const generate = useCallback(
async (input: GenerateInvitationCodesInput) => {
const res = await run({ input });
return res.data?.generateInvitationCodes ?? null;
},
[run],
);
return [generate, state] as const;
}
export function useDeleteInvitationCodes() {
const [run, state] = useGraphMutation<
DeleteInvitationCodesResponse,
{ ids: string[] }
>(DELETE_INVITATION_CODES_MUTATION);
const remove = useCallback(
async (ids: string[]) => {
const res = await run({ ids });
return res.data?.deleteInvitationCodes ?? false;
},
[run],
);
return [remove, state] as const;
}

View File

@@ -0,0 +1,61 @@
/**
* 登录日志 Hooks
*
* contract §2.4loginLogs
*/
import { useCallback, useState } from "react";
import { useGraphQuery } from "./use-graphql";
import { LOGIN_LOGS_QUERY } from "@/lib/graphql-client";
import type {
LoginLogViewModel,
PaginatedResult,
ListFilter,
} from "@/types/view-models";
interface LoginLogsResponse {
loginLogs: PaginatedResult<LoginLogViewModel>;
}
interface LoginLogFilter extends Partial<ListFilter> {
action?: string;
status?: string;
startDate?: number;
endDate?: number;
}
export function useLoginLogs(filter: LoginLogFilter) {
const result = useGraphQuery<LoginLogsResponse>(LOGIN_LOGS_QUERY, {
filter,
});
return {
...result,
data: result.data?.loginLogs ?? null,
};
}
export function useLoginLogFilter(initial?: Partial<LoginLogFilter>) {
const [filter, setFilter] = useState<LoginLogFilter>({
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 })),
[],
);
const setStatus = useCallback(
(status: string) =>
setFilter((f) => ({ ...f, status: status || undefined, page: 1 })),
[],
);
return { filter, setFilter, setPage, setSearch, setAction, setStatus };
}

View File

@@ -0,0 +1,99 @@
/**
* 学校组织管理 Hooks
*
* contract §2.4adminSchools / adminGrades / adminGradeInsights /
* adminDepartments / adminAcademicYears
*/
import { useGraphQuery } from "./use-graphql";
import {
ADMIN_SCHOOLS_QUERY,
ADMIN_GRADES_QUERY,
ADMIN_GRADE_INSIGHTS_QUERY,
ADMIN_DEPARTMENTS_QUERY,
ADMIN_ACADEMIC_YEARS_QUERY,
} from "@/lib/graphql-client";
import type {
SchoolViewModel,
GradeViewModel,
GradeInsightsViewModel,
DepartmentViewModel,
AcademicYearViewModel,
} from "@/types/view-models";
interface AdminSchoolsResponse {
adminSchools: SchoolViewModel[];
}
interface AdminGradesResponse {
adminGrades: GradeViewModel[];
}
interface AdminGradeInsightsResponse {
adminGradeInsights: GradeInsightsViewModel;
}
interface AdminDepartmentsResponse {
adminDepartments: DepartmentViewModel[];
}
interface AdminAcademicYearsResponse {
adminAcademicYears: AcademicYearViewModel[];
}
/** 学校列表 */
export function useSchools() {
const result = useGraphQuery<AdminSchoolsResponse>(ADMIN_SCHOOLS_QUERY);
return {
...result,
data: result.data?.adminSchools ?? [],
};
}
/** 年级列表(可按学校筛选) */
export function useGrades(schoolId?: string) {
const variables = schoolId ? { schoolId } : {};
const result = useGraphQuery<AdminGradesResponse>(
ADMIN_GRADES_QUERY,
variables,
);
return {
...result,
data: result.data?.adminGrades ?? [],
};
}
/** 年级洞察(按年级 ID */
export function useGradeInsights(gradeId: string | null | undefined) {
const result = useGraphQuery<AdminGradeInsightsResponse>(
ADMIN_GRADE_INSIGHTS_QUERY,
gradeId ? { gradeId } : {},
);
return {
...result,
data: result.data?.adminGradeInsights ?? null,
};
}
/** 部门/教研组列表(可按学校筛选) */
export function useDepartments(schoolId?: string) {
const variables = schoolId ? { schoolId } : {};
const result = useGraphQuery<AdminDepartmentsResponse>(
ADMIN_DEPARTMENTS_QUERY,
variables,
);
return {
...result,
data: result.data?.adminDepartments ?? [],
};
}
/** 学年/学期列表 */
export function useAcademicYears() {
const result = useGraphQuery<AdminAcademicYearsResponse>(
ADMIN_ACADEMIC_YEARS_QUERY,
);
return {
...result,
data: result.data?.adminAcademicYears ?? [],
};
}