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:
59
apps/admin-portal/src/hooks/use-ai-settings.ts
Normal file
59
apps/admin-portal/src/hooks/use-ai-settings.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AI 配置 Hooks
|
||||
*
|
||||
* contract §2.4:aiProviders / 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,
|
||||
};
|
||||
}
|
||||
128
apps/admin-portal/src/hooks/use-announcements.ts
Normal file
128
apps/admin-portal/src/hooks/use-announcements.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 公告管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminAnnouncements / 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;
|
||||
}
|
||||
39
apps/admin-portal/src/hooks/use-audit-overview.ts
Normal file
39
apps/admin-portal/src/hooks/use-audit-overview.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 审计总览 Hooks
|
||||
*
|
||||
* contract §2.4:auditOverviewStats / 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,
|
||||
};
|
||||
}
|
||||
97
apps/admin-portal/src/hooks/use-data-change-logs.ts
Normal file
97
apps/admin-portal/src/hooks/use-data-change-logs.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 数据变更日志 Hooks
|
||||
*
|
||||
* contract §2.4:dataChangeLogs
|
||||
*/
|
||||
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);
|
||||
}
|
||||
102
apps/admin-portal/src/hooks/use-files.ts
Normal file
102
apps/admin-portal/src/hooks/use-files.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 文件管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminFiles / 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 };
|
||||
}
|
||||
99
apps/admin-portal/src/hooks/use-invitation-codes.ts
Normal file
99
apps/admin-portal/src/hooks/use-invitation-codes.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 邀请码管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminInvitationCodes / 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;
|
||||
}
|
||||
61
apps/admin-portal/src/hooks/use-login-logs.ts
Normal file
61
apps/admin-portal/src/hooks/use-login-logs.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 登录日志 Hooks
|
||||
*
|
||||
* contract §2.4:loginLogs
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
99
apps/admin-portal/src/hooks/use-school.ts
Normal file
99
apps/admin-portal/src/hooks/use-school.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 学校组织管理 Hooks
|
||||
*
|
||||
* contract §2.4:adminSchools / 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 ?? [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user