From 67435e46ca2b3236da45038bb863fc004d6c3812 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 17:23:23 +0800 Subject: [PATCH 01/43] =?UTF-8?q?feat(admin):=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E4=BD=93=E7=B3=BB=E5=8C=96=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E4=B8=8E=E9=AB=98=E5=8D=B1=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX 缺陷修复: - 校验失败不再卡死弹窗按钮(Users/Roles/Bills) - 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选 - AI 表单/批量确认不再出现"假成功" - Dashboard 各数据模块独立加载,单接口失败不再整页清零 - 房间可视化加载失败显示错误态而非永久转圈 - 学生编辑表单回填前重置,避免字段残留污染 - 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单 - 金数据匹配关闭前确认,同步中禁止误关 体验提升: - 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试 - 全局 ErrorBoundary + RouteKeeper 逐页兜底 - 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据 - 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡 - 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期, ArtifactErrorBoundary 渲染降级,图表空数据占位 - AI 助手欢迎语与建议话术按角色定制,会话列表空态引导 - 更新 a2ui-contract.md 契约文档说明实现现状 --- .../components/AiChat/AiChatDrawer.parts.tsx | 8 + .../src/components/AiChat/AiChatDrawer.tsx | 18 +- .../components/AiChat/AiMessageContent.tsx | 37 +- .../AiChat/ArtifactErrorBoundary.tsx | 47 ++ .../src/components/AiChat/DynamicChart.tsx | 85 ++-- .../src/components/AiChat/DynamicForm.tsx | 84 ++-- .../src/components/AiChat/DynamicReview.tsx | 78 ++-- apps/admin/src/components/AiChat/style.css | 10 + .../AiChat/useAiChatMessageActions.tsx | 10 +- .../components/AiChat/useSubmissionState.ts | 85 ++++ .../src/components/AiChat/welcomeCopy.ts | 69 +++ .../admin/src/components/AppErrorBoundary.tsx | 52 +++ .../src/components/JinshujuMatchModal.tsx | 27 +- apps/admin/src/components/NextStepHint.tsx | 69 +++ .../src/components/Onboarding/RoleTour.tsx | 156 +++++++ .../src/components/QueryState/QueryEmpty.tsx | 35 ++ .../components/QueryState/QueryErrorState.tsx | 56 +++ apps/admin/src/components/QueryState/index.ts | 4 + apps/admin/src/components/RouteKeeper.tsx | 11 +- .../StudentProfileContent/index.tsx | 25 +- .../src/components/routeKeeperContext.ts | 10 + apps/admin/src/hooks/usePageVisible.ts | 37 ++ apps/admin/src/layouts/MainLayout.tsx | 2 + apps/admin/src/main.tsx | 11 +- .../Attendance/LessonAttendanceDetail.tsx | 92 ++-- apps/admin/src/pages/Attendance/admin.tsx | 209 +++++---- apps/admin/src/pages/Attendance/teacher.tsx | 26 +- apps/admin/src/pages/AttendanceDevices.tsx | 54 ++- apps/admin/src/pages/Bills/index.tsx | 74 +++- apps/admin/src/pages/Classes/detail.tsx | 117 +++-- apps/admin/src/pages/Classes/index.tsx | 61 +-- .../src/pages/ClassroomRentals/index.tsx | 65 +-- .../src/pages/ClassroomSchedule/index.tsx | 410 +++++++++--------- apps/admin/src/pages/Classrooms/index.tsx | 58 +-- apps/admin/src/pages/Dashboard/index.tsx | 124 ++++-- .../src/pages/Deposits/DepositModals.tsx | 1 + apps/admin/src/pages/Deposits/index.tsx | 75 +++- apps/admin/src/pages/Exams/index.tsx | 43 +- apps/admin/src/pages/Expenses/index.tsx | 245 ++++++----- apps/admin/src/pages/Occupancies/index.tsx | 123 +++--- apps/admin/src/pages/Roles/index.tsx | 2 +- apps/admin/src/pages/RoomVisual/index.tsx | 28 +- apps/admin/src/pages/Rooms/RoomDrawer.tsx | 27 +- apps/admin/src/pages/Rooms/RoomModals.tsx | 8 + apps/admin/src/pages/Rooms/index.tsx | 59 ++- .../src/pages/Schedules/ScheduleModals.tsx | 12 + apps/admin/src/pages/Schedules/index.tsx | 106 +++-- .../src/pages/Students/StudentsToolbar.tsx | 21 +- apps/admin/src/pages/Students/index.tsx | 87 ++-- .../src/pages/TeacherWorkspace/index.tsx | 28 +- apps/admin/src/pages/Teachers/index.tsx | 96 ++-- apps/admin/src/pages/Users/index.tsx | 4 +- apps/admin/src/pages/Wallets/index.tsx | 174 ++++---- scripts/a2ui-contract.md | 20 + 54 files changed, 2298 insertions(+), 1177 deletions(-) create mode 100644 apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx create mode 100644 apps/admin/src/components/AiChat/useSubmissionState.ts create mode 100644 apps/admin/src/components/AiChat/welcomeCopy.ts create mode 100644 apps/admin/src/components/AppErrorBoundary.tsx create mode 100644 apps/admin/src/components/NextStepHint.tsx create mode 100644 apps/admin/src/components/Onboarding/RoleTour.tsx create mode 100644 apps/admin/src/components/QueryState/QueryEmpty.tsx create mode 100644 apps/admin/src/components/QueryState/QueryErrorState.tsx create mode 100644 apps/admin/src/components/QueryState/index.ts create mode 100644 apps/admin/src/components/routeKeeperContext.ts create mode 100644 apps/admin/src/hooks/usePageVisible.ts diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx index 7ed21b68..8a159236 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx @@ -64,6 +64,14 @@ export const AiChatSidebar: React.FC = ({ } /> {loadingList && } + {!loadingList && !selectionMode && conversationCount === 0 ? ( +
+ 暂无会话 + + 点击「新对话」开始提问 + +
+ ) : null}
{selectionMode ? ( <> diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.tsx index c179270d..86c3d997 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.tsx @@ -20,8 +20,11 @@ import { } from 'antd'; import type { MenuProps } from 'antd'; import { message } from '../../ui/app-message'; +import { useUserStore } from '../../store/user/userStore'; +import { usePermissionStore } from '../../store/permission/permissionStore'; import { aiChatApi, conversationStreamUrl } from './api'; import { GongxueAiChatProvider } from './provider'; +import { welcomeDescription, workflowPromptExamples } from './welcomeCopy'; import { ImportWizardModal } from '../ImportWizard/ImportWizardModal'; import type { AiSkill } from './types'; import { useAiChatMessageActions } from './useAiChatMessageActions'; @@ -51,6 +54,8 @@ interface AiChatDrawerProps { const AiChatDrawer: React.FC = ({ open, onClose, onRequestingChange }) => { const { modal } = App.useApp(); + const user = useUserStore((state) => state.user); + const permissions = usePermissionStore((state) => state.permissions); const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; const [loadingList, setLoadingList] = useState(false); @@ -524,12 +529,21 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting title="你好,我是恭学 AI 助手" description={ lockedSkill?.description || - '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。' + welcomeDescription(user?.roles ?? [], permissions) } /> ({ + key: `workflow-${index}`, + label: item.label, + description: item.description, + }), + ), + ]} wrap onItemClick={({ data }) => submit(String(data.label || ''))} /> diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index f176047d..1c221a72 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -16,6 +16,7 @@ import { useUserStore } from '../../store/user/userStore'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; +import { ArtifactErrorBoundary } from './ArtifactErrorBoundary'; import { LiteCodeHighlighter } from './LiteCodeHighlighter'; import { LiteMermaid } from './LiteMermaid'; import type { @@ -355,26 +356,30 @@ export const AiMessageContent: React.FC = ({ /> )} {(message.forms ?? []).map((form) => ( - onSubmitForm?.(form, values)} - /> + + onSubmitForm?.(form, values)} + /> + ))} {(message.reviews ?? []).map((review: AiReviewSchema) => ( - onSubmitReview?.(reviewId, review.title)} - onConfirmStep={onConfirmReviewStep} - onConfirmGroup={onConfirmReviewGroup} - /> + + onSubmitReview?.(reviewId, review.title)} + onConfirmStep={onConfirmReviewStep} + onConfirmGroup={onConfirmReviewGroup} + /> + ))} {(message.charts ?? []).map((chart: AiChartSchema) => ( - + + + ))} {message.error && } {message.cancelled && 回答已停止} diff --git a/apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx b/apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx new file mode 100644 index 00000000..71fb3864 --- /dev/null +++ b/apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Alert } from 'antd'; + +interface ArtifactErrorBoundaryProps { + children: React.ReactNode; + /** 制品标题(用于错误提示文案) */ + title?: string; +} + +interface ArtifactErrorBoundaryState { + hasError: boolean; +} + +/** + * A2UI 制品(表单/审查卡/图表)渲染错误兜底:单个制品渲染失败只降级为 + * 错误占位卡片,不影响同气泡内其他消息与制品。 + */ +export class ArtifactErrorBoundary extends React.Component< + ArtifactErrorBoundaryProps, + ArtifactErrorBoundaryState +> { + state: ArtifactErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): ArtifactErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + console.error('[ArtifactErrorBoundary] 制品渲染异常:', error, info.componentStack); + } + + render(): React.ReactNode { + if (this.state.hasError) { + return ( + + ); + } + return this.props.children; + } +} + +export default ArtifactErrorBoundary; diff --git a/apps/admin/src/components/AiChat/DynamicChart.tsx b/apps/admin/src/components/AiChat/DynamicChart.tsx index fc9c78ef..58ea6d71 100644 --- a/apps/admin/src/components/AiChat/DynamicChart.tsx +++ b/apps/admin/src/components/AiChat/DynamicChart.tsx @@ -1,10 +1,11 @@ -import React, { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react'; import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card'; import { Button, Spin, Tag, Tooltip, Typography } from 'antd'; import { DownloadOutlined } from '@ant-design/icons'; import type { EChartsType } from 'echarts/core'; import type { EChartsOption } from '../../components/ECharts'; import type { AiChartSchema } from './types'; +import { useXCardSurface } from './useSubmissionState'; // echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取 const ReactECharts = lazy(() => import('../../components/ECharts')); @@ -202,6 +203,27 @@ const ChartPreview: React.FC = ({ chart }) => { const option = useMemo(() => (chart ? buildOption(chart) : {}), [chart]); const [instance, setInstance] = useState(null); if (!chart) return null; + // 空数据集:渲染明确占位,而不是一张空白图 + if (!chart.rows || chart.rows.length === 0) { + return ( +
+
+ {chart.title} + {CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType} +
+
+ 暂无数据 +
+
+ ); + } const downloadImage = () => { if (!instance) return; @@ -254,46 +276,39 @@ export interface DynamicChartProps { * so history replays identically. */ export const DynamicChart: React.FC = ({ chart }) => { - const commandsRef = useRef([]); - const [commands, setCommands] = useState([]); - const idRef = useRef(''); + const sid = surfaceId(chart.id); + const { commands, pushCommands } = useXCardSurface(sid); useEffect(() => { - const sid = surfaceId(chart.id); - if (idRef.current !== sid) { - commandsRef.current = []; - idRef.current = sid; - } - const cmds = commandsRef.current; - if (cmds.length === 0) { - cmds.push({ + const cmds: XAgentCommand_v0_9[] = [ + { version: 'v0.9', createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID }, - }); - } - cmds.push({ - version: 'v0.9', - updateDataModel: { - surfaceId: sid, - path: '/chart', - value: chart, }, - }); - cmds.push({ - version: 'v0.9', - updateComponents: { - surfaceId: sid, - components: [ - { - id: 'root', - component: 'ChartPreview', - chart: { path: '/chart' }, - }, - ], + { + version: 'v0.9', + updateDataModel: { + surfaceId: sid, + path: '/chart', + value: chart, + }, }, - }); - setCommands([...cmds]); - }, [chart]); + { + version: 'v0.9', + updateComponents: { + surfaceId: sid, + components: [ + { + id: 'root', + component: 'ChartPreview', + chart: { path: '/chart' }, + }, + ], + }, + }, + ]; + pushCommands(cmds); + }, [chart, pushCommands, sid]); return (
diff --git a/apps/admin/src/components/AiChat/DynamicForm.tsx b/apps/admin/src/components/AiChat/DynamicForm.tsx index 8148f58d..58d4eeb5 100644 --- a/apps/admin/src/components/AiChat/DynamicForm.tsx +++ b/apps/admin/src/components/AiChat/DynamicForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { XCard, registerCatalog, @@ -18,6 +18,7 @@ import { } from 'antd'; import dayjs from 'dayjs'; import type { AiFormField, AiFormSchema } from './types'; +import { useSubmissionState, useXCardSurface } from './useSubmissionState'; const FORM_CATALOG_ID = 'gongxue-form-catalog'; @@ -179,63 +180,46 @@ export interface DynamicFormProps { * success/failure/loading transitions are pushed as incremental commands. */ export const DynamicForm: React.FC = ({ form, disabled, onSubmit }) => { - const [submitting, setSubmitting] = useState(false); - const [submitted, setSubmitted] = useState(false); - const [error, setError] = useState(null); - const commandsRef = useRef([]); - const [commands, setCommands] = useState([]); - const idRef = useRef(''); + const sid = surfaceId(form.id); + const { submitting, submitted, error, run } = useSubmissionState(); + const { commands, pushCommands } = useXCardSurface(sid); useEffect(() => { - const sid = surfaceId(form.id); - if (idRef.current !== sid) { - commandsRef.current = []; - idRef.current = sid; - } - const cmds = commandsRef.current; - if (cmds.length === 0) { - cmds.push({ + const cmds: XAgentCommand_v0_9[] = [ + { version: 'v0.9', createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID }, - }); - } - cmds.push({ - version: 'v0.9', - updateDataModel: { - surfaceId: sid, - path: '/form', - value: { ...form, submitting, submitted, error }, }, - }); - cmds.push({ - version: 'v0.9', - updateComponents: { - surfaceId: sid, - components: [ - { - id: 'root', - component: 'FormPreview', - form: { path: '/form' }, - disabled: Boolean(disabled), - }, - ], + { + version: 'v0.9', + updateDataModel: { + surfaceId: sid, + path: '/form', + value: { ...form, submitting, submitted, error }, + }, }, - }); - setCommands([...cmds]); - }, [disabled, error, form, submitted, submitting]); + { + version: 'v0.9', + updateComponents: { + surfaceId: sid, + components: [ + { + id: 'root', + component: 'FormPreview', + form: { path: '/form' }, + disabled: Boolean(disabled), + }, + ], + }, + }, + ]; + pushCommands(cmds); + }, [disabled, error, form, pushCommands, sid, submitted, submitting]); - const handleSubmit = async (values: Record) => { - if (submitting) return; - setSubmitting(true); - setError(null); - try { + const handleSubmit = (values: Record) => { + void run(async () => { await onSubmit(values); - setSubmitted(true); - } catch (reason) { - setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试'); - } finally { - setSubmitting(false); - } + }); }; const handleAction = (payload: ActionPayload) => { diff --git a/apps/admin/src/components/AiChat/DynamicReview.tsx b/apps/admin/src/components/AiChat/DynamicReview.tsx index 0f4a8de8..aec468a6 100644 --- a/apps/admin/src/components/AiChat/DynamicReview.tsx +++ b/apps/admin/src/components/AiChat/DynamicReview.tsx @@ -17,6 +17,7 @@ import { type TableProps, } from 'antd'; import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types'; +import { useXCardSurface } from './useSubmissionState'; import { GROUP_STATUS_LABELS, SECTION_ORDER, @@ -430,9 +431,8 @@ export const DynamicReview: React.FC = ({ activeTypeRef.current = activeType; const [localReview, setLocalReview] = useState(review); const [error, setError] = useState(null); - const commandsRef = useRef([]); - const [commands, setCommands] = useState([]); - const idRef = useRef(''); + const sid = surfaceId(localReview.id); + const { commands, pushCommands } = useXCardSurface(sid); useEffect(() => { setLocalReview(review); @@ -455,55 +455,51 @@ export const DynamicReview: React.FC = ({ }, [review]); useEffect(() => { - const sid = surfaceId(localReview.id); - if (idRef.current !== sid) { - commandsRef.current = []; - idRef.current = sid; - } - const cmds = commandsRef.current; - if (cmds.length === 0) { - cmds.push({ + const cmds: XAgentCommand_v0_9[] = [ + { version: 'v0.9', createSurface: { surfaceId: sid, catalogId: REVIEW_CATALOG_ID }, - }); - } - cmds.push({ - version: 'v0.9', - updateDataModel: { - surfaceId: sid, - path: '/review', - value: { - ...localReview, - submitting, - activeKey, - activeType, - submittingKey, - submittingGroup, - error, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: sid, + path: '/review', + value: { + ...localReview, + submitting, + activeKey, + activeType, + submittingKey, + submittingGroup, + error, + }, }, }, - }); - cmds.push({ - version: 'v0.9', - updateComponents: { - surfaceId: sid, - components: [ - { - id: 'root', - component: 'ReviewPreview', - review: { path: '/review' }, - disabled: Boolean(disabled), - }, - ], + { + version: 'v0.9', + updateComponents: { + surfaceId: sid, + components: [ + { + id: 'root', + component: 'ReviewPreview', + review: { path: '/review' }, + disabled: Boolean(disabled), + }, + ], + }, }, - }); - setCommands([...cmds]); + ]; + pushCommands(cmds); }, [ activeKey, activeType, disabled, error, localReview, + pushCommands, + sid, submitting, submittingGroup, submittingKey, diff --git a/apps/admin/src/components/AiChat/style.css b/apps/admin/src/components/AiChat/style.css index ab1ac75b..14ae4c30 100644 --- a/apps/admin/src/components/AiChat/style.css +++ b/apps/admin/src/components/AiChat/style.css @@ -138,6 +138,16 @@ inset: 68px 0 auto; } +.ai-chat-sidebar__empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 24px 16px; +} + .ai-chat-sidebar__footer { flex: none; display: flex; diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index 4d7bdeaa..043032c2 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -327,8 +327,9 @@ export function useAiChatMessageActions({ ); const submitForm = useCallback( - (form: AiFormSchema, values: Record) => { - if (!activeId || isRequesting) return; + async (form: AiFormSchema, values: Record): Promise => { + if (!activeId) throw new Error('当前会话不可用,请稍后重试'); + if (isRequesting) throw new Error('请等待当前 AI 回复完成后再提交表单'); requestWithStatus({ message: '表单提交', attachmentIds: [], @@ -342,8 +343,9 @@ export function useAiChatMessageActions({ ); const submitReview = useCallback( - (reviewId: string, reviewTitle?: string) => { - if (!activeId || isRequesting) return; + async (reviewId: string, reviewTitle?: string): Promise => { + if (!activeId) throw new Error('当前会话不可用,请稍后重试'); + if (isRequesting) throw new Error('请等待当前 AI 回复完成后再确认导入'); requestWithStatus({ message: '确认批量导入', attachmentIds: [], diff --git a/apps/admin/src/components/AiChat/useSubmissionState.ts b/apps/admin/src/components/AiChat/useSubmissionState.ts new file mode 100644 index 00000000..60fe47e6 --- /dev/null +++ b/apps/admin/src/components/AiChat/useSubmissionState.ts @@ -0,0 +1,85 @@ +import { useCallback, useRef, useState } from 'react'; +import type { XAgentCommand_v0_9 } from '@ant-design/x-card'; + +/** + * 提交状态管理:收敛 DynamicForm / DynamicReview 中重复的 + * submitting / submitted / error 状态与「防重复提交 + 失败可重试」逻辑。 + * + * 用法: + * const { submitting, submitted, error, run } = useSubmissionState(); + * const handleSubmit = (values) => run(async () => { await onSubmit(values); }); + */ +export function useSubmissionState() { + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + const submittingRef = useRef(false); + + const run = useCallback(async (task: () => Promise | void) => { + if (submittingRef.current) return; + submittingRef.current = true; + setSubmitting(true); + setError(null); + try { + await task(); + setSubmitted(true); + } catch (reason) { + setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试'); + } finally { + submittingRef.current = false; + setSubmitting(false); + } + }, []); + + const reset = useCallback(() => { + setSubmitted(false); + setError(null); + }, []); + + return { submitting, submitted, error, run, reset }; +} + +/** + * A2UI surface 的 XCard commands 增量更新生命周期: + * 每个 surface 只创建一次,后续通过 updateDataModel / updateComponents 增量更新。 + * + * 用法: + * const { pushCommands } = useXCardSurface(surfaceId); + * useEffect(() => { + * pushCommands([ + * { version: 'v0.9', createSurface: { surfaceId, catalogId } }, + * { version: 'v0.9', updateDataModel: { surfaceId, path: '/x', value } }, + * { version: 'v0.9', updateComponents: { surfaceId, components } }, + * ]); + * }, [value]); + */ +export function useXCardSurface(surfaceId: string) { + const commandsRef = useRef([]); + const [commands, setCommands] = useState([]); + const idRef = useRef(''); + + const pushCommands = useCallback( + (cmds: XAgentCommand_v0_9[]) => { + if (cmds.length === 0) return; + // 同一 surface 的 createSurface 命令只允许出现一次,自动去重 + const hasSurface = commandsRef.current.some( + (c) => 'createSurface' in c && c.createSurface.surfaceId === surfaceId, + ); + const filtered = hasSurface + ? cmds.filter((c) => !('createSurface' in c)) + : cmds; + commandsRef.current = [...commandsRef.current, ...filtered]; + setCommands([...commandsRef.current]); + }, + [surfaceId], + ); + + const surfaceKey = surfaceId; + if (idRef.current !== surfaceKey) { + // 组件复用到新 surface 时,清空历史命令重新初始化 + commandsRef.current = []; + idRef.current = surfaceKey; + } + + return { commands, pushCommands }; +} diff --git a/apps/admin/src/components/AiChat/welcomeCopy.ts b/apps/admin/src/components/AiChat/welcomeCopy.ts new file mode 100644 index 00000000..1fbee621 --- /dev/null +++ b/apps/admin/src/components/AiChat/welcomeCopy.ts @@ -0,0 +1,69 @@ +import { getRoleDomains } from '../../auth/menu-policy'; + +/** + * 按用户角色生成 AI 助手欢迎语,引导用户使用与其岗位匹配的业务闭环。 + * 优先级:教师 > 住宿运营 > 教务 > 教室运营 > 系统/超管 > 兜底。 + */ +export function welcomeDescription(roles: readonly string[], permissions: readonly string[]): string { + const domains = getRoleDomains(roles, permissions); + + if (domains.has('teacher')) { + return '我可以帮你查询今日课程、拉取钉钉考勤、查看排课。课程开始后就能看到打卡结果。'; + } + if (domains.has('accommodation')) { + return '我可以帮你完成「宿舍档案 → 学生 → 入住 → 费用 → 账单」的住宿计费闭环,先告诉我你手头有什么数据。'; + } + if (domains.has('academic')) { + return '我可以帮你完成「学生档案 → 分班 → 排课 → 考勤」的教学闭环,支持 Excel 批量导入与预览确认。'; + } + if (domains.has('classroom')) { + return '我可以帮你管理教室排期与租赁订单,查询占用情况,避免时间冲突。'; + } + if (domains.has('system') || domains.has('super')) { + return '我是恭学 AI 助手。我可以查询经营数据、管理业务数据、生成批量导入预览——所有写操作都会先经你确认。'; + } + return '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'; +} + +/** + * 与角色匹配的业务闭环引导示例(用于 Prompts 建议话术)。 + * 返回空数组表示当前角色无匹配示例。 + */ +export function workflowPromptExamples( + roles: readonly string[], + permissions: readonly string[], +): { label: string; description: string }[] { + const domains = getRoleDomains(roles, permissions); + const examples: { label: string; description: string }[] = []; + + if (domains.has('academic')) { + examples.push( + { label: '帮我从 Excel 导入学生并完成分班', description: '教学闭环' }, + { label: '查一下这周有哪些班级还没排课', description: '教学闭环' }, + ); + } + if (domains.has('accommodation')) { + examples.push( + { label: '帮我从 Excel 导入学生并安排入住', description: '住宿计费闭环' }, + { label: '查一下本月还没生成账单的入住学生', description: '住宿计费闭环' }, + ); + } + if (domains.has('classroom')) { + examples.push( + { label: '查一下这间教室本周的占用情况', description: '教室运营' }, + ); + } + if (domains.has('teacher')) { + examples.push( + { label: '今天我有哪几节课?', description: '今日教学' }, + ); + } + if (domains.has('system') || domains.has('super')) { + examples.push( + { label: '看一下本月的经营概览', description: '数据面板' }, + { label: '帮我梳理宿舍计费的完整流程', description: '业务流程' }, + ); + } + + return examples; +} diff --git a/apps/admin/src/components/AppErrorBoundary.tsx b/apps/admin/src/components/AppErrorBoundary.tsx new file mode 100644 index 00000000..375056b0 --- /dev/null +++ b/apps/admin/src/components/AppErrorBoundary.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Button, Result } from 'antd'; + +interface AppErrorBoundaryProps { + children: React.ReactNode; + /** 自定义降级内容;不传则使用默认错误卡片 */ + fallback?: React.ReactNode; +} + +interface AppErrorBoundaryState { + hasError: boolean; +} + +/** + * 全局渲染错误兜底:捕获子树内的渲染异常,展示可恢复的错误卡片, + * 避免单个页面/组件崩溃导致整个应用白屏。 + */ +export class AppErrorBoundary extends React.Component< + AppErrorBoundaryProps, + AppErrorBoundaryState +> { + state: AppErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): AppErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + console.error('[AppErrorBoundary] 渲染异常:', error, info.componentStack); + } + + render(): React.ReactNode { + if (this.state.hasError) { + if (this.props.fallback) return this.props.fallback; + return ( + window.location.reload()}> + 刷新页面 + + } + /> + ); + } + return this.props.children; + } +} + +export default AppErrorBoundary; diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index c06fbe35..11d228e8 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useImmer } from 'use-immer'; -import { Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd'; +import { App, Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd'; import { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'; import api from '../api'; import { message } from '../ui/app-message'; @@ -30,6 +30,7 @@ interface MatchModalProps { } const JinshujuMatchModal: React.FC = ({ open, onClose, onApplied }) => { + const { modal } = App.useApp(); const { hasPermission, hasAllPermissions, permissionsReady } = usePermission(); const canTriggerSync = hasPermission('sync:trigger'); const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger'); @@ -168,6 +169,22 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie }; const handleClose = () => { + // 已进入匹配步骤且存在待处理数据时,关闭会丢失全部决策,需先确认 + const hasPendingWork = step === 'match' && (entries.length > 0 || decisions.size > 0); + if (hasPendingWork) { + modal.confirm({ + title: '放弃当前匹配?', + content: '已确认的匹配决策将全部丢失,且不会写入系统。', + okText: '放弃', + okButtonProps: { danger: true }, + cancelText: '继续匹配', + onOk: () => { + reset(); + onClose(); + }, + }); + return; + } reset(); onClose(); }; @@ -330,6 +347,7 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie onCancel={handleClose} width={step === 'match' || step === 'applying' ? 900 : 640} mask={{ closable: false }} + closable={step !== 'applying'} footer={ step === 'connection' ? [ @@ -380,7 +398,12 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie {step === 'rule' ? renderRuleStep() : null} {step === 'match' ? renderMatchStep() : null} {step === 'applying' ? ( - + <> + + + 数据正在写入,关闭窗口不会中断同步 + + ) : null} ); diff --git a/apps/admin/src/components/NextStepHint.tsx b/apps/admin/src/components/NextStepHint.tsx new file mode 100644 index 00000000..d9b4a571 --- /dev/null +++ b/apps/admin/src/components/NextStepHint.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import { Button, Card, Flex, Typography } from 'antd'; +import { CloseOutlined, RightOutlined, StepForwardOutlined } from '@ant-design/icons'; + +export interface NextStepHintProps { + /** 提示标题,如「下一步:分班」 */ + title: string; + /** 补充说明 */ + description?: string; + /** 主操作按钮(跳转到下一步) */ + action?: { label: string; onClick: () => void }; + /** 是否可关闭,默认 true */ + closable?: boolean; + /** 关闭回调 */ + onClose?: () => void; +} + +/** + * 「下一步」引导卡片:在操作成功后或空状态下提示用户业务闭环的下一步, + * 让用户始终知道接下来该做什么。 + */ +export const NextStepHint: React.FC = ({ + title, + description, + action, + closable = true, + onClose, +}) => { + const [dismissed, setDismissed] = useState(false); + if (dismissed) return null; + + return ( + + + + + {title} + {description ? {description} : null} + + + {action ? ( + + ) : null} + {closable ? ( + + ) : null} + + ); +}; + +export default QueryEmpty; diff --git a/apps/admin/src/components/QueryState/QueryErrorState.tsx b/apps/admin/src/components/QueryState/QueryErrorState.tsx new file mode 100644 index 00000000..9056d1e7 --- /dev/null +++ b/apps/admin/src/components/QueryState/QueryErrorState.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Button, Result, Typography } from 'antd'; + +export interface QueryErrorStateProps { + /** 错误标题,默认「数据加载失败」 */ + title?: string; + /** 错误描述,默认「请检查网络后重试」 */ + description?: string; + /** 点击重试回调;不传则不显示重试按钮 */ + onRetry?: () => void; + /** 紧凑模式:用于表格内部、弹窗等空间受限场景 */ + compact?: boolean; +} + +/** + * 统一查询错误态:任何数据加载失败都应渲染本组件(而非伪装成空状态), + * 并提供重试入口,让用户明确知道「加载失败」而非「没有数据」。 + */ +export const QueryErrorState: React.FC = ({ + title = '数据加载失败', + description = '请检查网络后重试。', + onRetry, + compact = false, +}) => { + if (compact) { + return ( +
+ {title} + {description ? ( +
+ + {description} + +
+ ) : null} + {onRetry ? ( +
+ +
+ ) : null} +
+ ); + } + return ( + 重试 : undefined} + /> + ); +}; + +export default QueryErrorState; diff --git a/apps/admin/src/components/QueryState/index.ts b/apps/admin/src/components/QueryState/index.ts new file mode 100644 index 00000000..32276568 --- /dev/null +++ b/apps/admin/src/components/QueryState/index.ts @@ -0,0 +1,4 @@ +export { QueryErrorState } from './QueryErrorState'; +export type { QueryErrorStateProps } from './QueryErrorState'; +export { QueryEmpty } from './QueryEmpty'; +export type { QueryEmptyProps, QueryEmptyAction } from './QueryEmpty'; diff --git a/apps/admin/src/components/RouteKeeper.tsx b/apps/admin/src/components/RouteKeeper.tsx index a07a8b7d..4c1de962 100644 --- a/apps/admin/src/components/RouteKeeper.tsx +++ b/apps/admin/src/components/RouteKeeper.tsx @@ -1,11 +1,16 @@ import React, { useRef } from 'react'; import { useLocation, useOutlet } from 'react-router'; +import AppErrorBoundary from './AppErrorBoundary'; +import { ActivePageContext } from './routeKeeperContext'; const MAX_CACHED_PAGES = 30; /** * 路由保活:切换页面时保留已访问页面的组件实例(输入、滚动、弹窗状态不丢失)。 * 隐藏页面仍挂载在 DOM 中,仅通过 display:none 隐藏。 + * + * - 每个缓存页外层包裹 AppErrorBoundary:单页渲染异常不影响其他缓存页。 + * - 通过 ActivePageContext 向页面暴露「当前激活页路径」,供 usePageVisible 使用。 */ export const RouteKeeper: React.FC = () => { const location = useLocation(); @@ -26,17 +31,17 @@ export const RouteKeeper: React.FC = () => { } return ( - <> + {Array.from(cacheRef.current.entries()).map(([key, node]) => (
- {node} + {node}
))} - +
); }; diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 549e4217..ac49ff59 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -31,7 +31,7 @@ import { validateResponse } from '../../utils/validate'; import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas'; import EditableCell from '../EditableCell'; import { usePermission } from '../../hooks/usePermission'; -import { getErrorMessage } from '../../utils/error'; +import { QueryErrorState } from '../QueryState'; import { ADMISSION_STATUS_MAP, ATTENDANCE_STATUS_MAP, SESSION_LABELS, getOptionLabel } from './shared'; import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared'; @@ -478,19 +478,15 @@ const StudentProfileContent: React.FC = ({ data: aggregateData, isLoading, isFetching, + isError, refetch, } = useQuery({ queryKey: ['archive', studentId], queryFn: async () => { - try { - return validateResponse( - studentProfileAggregateSchema, - await api.get(`/archive/${studentId}`), - ); - } catch (e: unknown) { - message.error(getErrorMessage(e, '加载失败')); - return null; - } + return validateResponse( + studentProfileAggregateSchema, + await api.get(`/archive/${studentId}`), + ); }, }); const { data: organizations = [] } = useQuery< @@ -582,6 +578,15 @@ const StudentProfileContent: React.FC = ({
); } + if (isError) { + return ( + void refetch()} + /> + ); + } return null; } diff --git a/apps/admin/src/components/routeKeeperContext.ts b/apps/admin/src/components/routeKeeperContext.ts new file mode 100644 index 00000000..fa32c6bc --- /dev/null +++ b/apps/admin/src/components/routeKeeperContext.ts @@ -0,0 +1,10 @@ +import { createContext, useContext } from 'react'; + +/** + * 当前激活的页面路径(由 RouteKeeper 提供)。 + * RouteKeeper 用 display:none 保活已访问页面,页面组件本身不会重新挂载, + * 因此需要该上下文让每个缓存页感知「自己是否处于激活状态」。 + */ +export const ActivePageContext = createContext(''); + +export const useActivePage = (): string => useContext(ActivePageContext); diff --git a/apps/admin/src/hooks/usePageVisible.ts b/apps/admin/src/hooks/usePageVisible.ts new file mode 100644 index 00000000..3b6df686 --- /dev/null +++ b/apps/admin/src/hooks/usePageVisible.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router'; +import { useQueryClient, type QueryKey } from '@tanstack/react-query'; +import { useActivePage } from '../components/routeKeeperContext'; + +/** + * 当前页面是否处于激活(可见)状态。 + * RouteKeeper 保活页面始终挂载,只有激活页可见;配合 + * `useVisibleRefetch` 可在切回页面时刷新数据。 + */ +export function usePageVisible(): boolean { + const activePage = useActivePage(); + const location = useLocation(); + return activePage === location.pathname; +} + +/** + * 页面重新变为可见时刷新指定 queryKey 的数据。 + * 解决 RouteKeeper 保活导致的「切回列表页看不到新增/删除数据」问题。 + * + * 用法:`useVisibleRefetch(['students']);` + * + * 注意:queryKey 通过 ref 持有,effect 只依赖 visible, + * 避免调用方每次渲染传入新数组字面量导致频繁重复请求。 + */ +export function useVisibleRefetch(queryKey: QueryKey | undefined): void { + const visible = usePageVisible(); + const queryClient = useQueryClient(); + const keyRef = useRef(queryKey); + keyRef.current = queryKey; + + useEffect(() => { + if (visible && keyRef.current) { + void queryClient.refetchQueries({ queryKey: keyRef.current }); + } + }, [visible, queryClient]); +} diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 4a210b7c..6b33e960 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -40,6 +40,7 @@ import { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/ import NotificationBell from '../components/NotificationBell'; import RouteDock from '../components/RouteDock'; import RouteKeeper from '../components/RouteKeeper'; +import RoleTour from '../components/Onboarding/RoleTour'; import { buildMenu, type AppMenuItem } from '../auth/menu-policy'; const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer')); @@ -431,6 +432,7 @@ const MainLayout: React.FC = () => { /> )} + ); }; diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx index 9a36429d..ba980178 100644 --- a/apps/admin/src/main.tsx +++ b/apps/admin/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import App from './App'; +import AppErrorBoundary from './components/AppErrorBoundary'; import './index.css'; import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; @@ -42,9 +43,11 @@ if (!rootElement) throw new Error('未找到 #root 挂载点'); ReactDOM.createRoot(rootElement).render( - - - {import.meta.env.DEV && } - + + + + {import.meta.env.DEV && } + + , ); diff --git a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx index f1fce2bf..4075ac73 100644 --- a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx +++ b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd'; import dayjs from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { QueryErrorState } from '../../components/QueryState'; import { filterLessonAttendanceRecords, getPunchDisplayInfo, @@ -84,37 +85,44 @@ const LessonAttendanceDetail: React.FC = ({ const [session, setSession] = useState(null); const [records, setRecords] = useState([]); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); const [keyword, setKeyword] = useState(''); const [filter, setFilter] = useState('all'); + // 组件以 key 重挂载(关闭/切换课节),卸载后 in-flight 请求不再更新状态或弹提示 + const cancelledRef = useRef(false); + + const loadLesson = useCallback(async () => { + if (!schedule) return; + setLoading(true); + setError(null); + const date = dayjs().format('YYYY-MM-DD'); + try { + const data = await api.post( + `/attendance-lessons/schedules/${schedule.id}/pull`, + { date }, + ); + if (cancelledRef.current) return; + setLoadedSchedule(data.schedule); + setSession(data.session); + setRecords(data.records); + message.success('钉钉打卡已更新;课程截止后系统将自动结算'); + } catch (error: unknown) { + if (cancelledRef.current) return; + setError(getErrorMessage(error, '加载本节课考勤失败')); + } finally { + if (!cancelledRef.current) setLoading(false); + } + }, [schedule]); useEffect(() => { if (!schedule) return; - let cancelled = false; + cancelledRef.current = false; setLoadedSchedule(schedule); - setLoading(true); - const date = dayjs().format('YYYY-MM-DD'); - void api - .post(`/attendance-lessons/schedules/${schedule.id}/pull`, { - date, - }) - .then((data) => { - if (cancelled) return; - setLoadedSchedule(data.schedule); - setSession(data.session); - setRecords(data.records); - message.success('钉钉打卡已更新;课程截止后系统将自动结算'); - }) - .catch((error: unknown) => { - if (cancelled) return; - message.error(getErrorMessage(error, '加载本节课考勤失败')); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); + void loadLesson(); return () => { - cancelled = true; + cancelledRef.current = true; }; - }, [schedule]); + }, [schedule, loadLesson]); const updateRecord = useCallback(async (record: LessonAttendanceRecord, status: string) => { const previous = record.status; @@ -185,19 +193,26 @@ const LessonAttendanceDetail: React.FC = ({ 显示 {filteredRecords.length} / {records.length} 人
- - rowKey="id" - loading={loading} - dataSource={filteredRecords} - pagination={false} - locale={{ - emptyText: ( - - ), - }} + {error ? ( + void loadLesson()} + /> + ) : ( + + rowKey="id" + loading={loading} + dataSource={filteredRecords} + pagination={false} + locale={{ + emptyText: ( + + ), + }} columns={[ { title: '学生', @@ -264,7 +279,8 @@ const LessonAttendanceDetail: React.FC = ({ render: (value: string | null) => value || , }, ]} - /> + /> + )} ); }; diff --git a/apps/admin/src/pages/Attendance/admin.tsx b/apps/admin/src/pages/Attendance/admin.tsx index 6768de6c..1306fcdb 100644 --- a/apps/admin/src/pages/Attendance/admin.tsx +++ b/apps/admin/src/pages/Attendance/admin.tsx @@ -11,13 +11,14 @@ import { attendanceSummarySchema, dingTalkSyncStatusSchema, } from '../../api/schemas'; -import { Form, Grid } from 'antd'; +import { App, Form, Grid } from 'antd'; import dayjs, { type Dayjs } from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; import { useUserStore } from '../../store/user/userStore'; import type { AttendanceSummary } from './attendance-workspace'; -import { getErrorMessage } from '../../utils/error'; +import { QueryErrorState } from '../../components/QueryState'; +import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { AttendanceAdminHeader } from './AttendanceAdminHeader'; import { buildAttendanceAdminColumns } from './AttendanceAdminColumns'; import { PeriodConfigModal, StudentDetailDrawer } from './AttendanceAdminModals'; @@ -39,6 +40,7 @@ import { } from './AttendanceAdmin.helpers'; export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { + const { modal } = App.useApp(); const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; const [periodModalOpen, setPeriodModalOpen] = useState(false); @@ -56,6 +58,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit const [selectedStudent, setSelectedStudent] = useState(null); const [correctingRecordId, setCorrectingRecordId] = useState(null); const queryClient = useQueryClient(); + useVisibleRefetch(['attendance', 'records']); const recordQueryKey = [ 'attendance', 'records', @@ -70,42 +73,27 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit const { data: classOptions = [] } = useQuery({ queryKey: ['attendance', 'meta', 'classes'], - queryFn: async () => { - try { - return validateResponse( - attendanceClassOptionsSchema, - await api.get('/attendance-records/classes'), - ); - } catch { - return []; - } - }, + queryFn: async () => + validateResponse( + attendanceClassOptionsSchema, + await api.get('/attendance-records/classes'), + ), }); const { data: alerts = [] } = useQuery({ queryKey: ['attendance', 'meta', 'alerts'], - queryFn: async () => { - try { - return validateResponse( - attendanceAlertsSchema, - await api.get('/attendance-records/alerts'), - ); - } catch { - return []; - } - }, + queryFn: async () => + validateResponse( + attendanceAlertsSchema, + await api.get('/attendance-records/alerts'), + ), }); const { data: periods = DEFAULT_ATTENDANCE_PERIODS } = useQuery({ queryKey: ['attendance', 'meta', 'periods'], - queryFn: async () => { - try { - return validateResponse( - attendancePeriodsSchema, - await api.get('/attendance-period-configs'), - ); - } catch { - return DEFAULT_ATTENDANCE_PERIODS; - } - }, + queryFn: async () => + validateResponse( + attendancePeriodsSchema, + await api.get('/attendance-period-configs'), + ), }); const { data: scheduleOptions = [], @@ -114,18 +102,13 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit queryKey: ['attendance', 'schedules', classId, attendanceDate], enabled: !!classId && !!attendanceDate, queryFn: async () => { - try { - if (!attendanceDate) return []; - return validateResponse( - attendanceScheduleOptionsSchema, - await api.get('/attendance-records/schedules', { - params: { classId, date: attendanceDate.format('YYYY-MM-DD') }, - }), - ); - } catch (error: unknown) { - message.error(getErrorMessage(error, '加载班级科目失败')); - return []; - } + if (!attendanceDate) return []; + return validateResponse( + attendanceScheduleOptionsSchema, + await api.get('/attendance-records/schedules', { + params: { classId, date: attendanceDate.format('YYYY-MM-DD') }, + }), + ); }, }); const scheduleOptionsLoading = scheduleOptionsFetching; @@ -187,13 +170,24 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit } }; - const resetPeriodConfig = async () => { - try { - await resetPeriodConfigMutation.mutateAsync(); - message.success('已恢复默认考勤时段'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } + const resetPeriodConfig = () => { + modal.confirm({ + title: '恢复默认考勤时段?', + content: '当前自定义的考勤时段配置将被系统默认值覆盖,此操作不可撤销。', + okText: '恢复默认', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + const data = await resetPeriodConfigMutation.mutateAsync(); + // 同步回填表单,避免界面仍显示旧配置、用户再点保存把旧值写回 + periodForm.setFieldsValue({ periods: data }); + message.success('已恢复默认考勤时段'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); }; const buildParams = useCallback( @@ -216,22 +210,18 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery({ queryKey: ['attendance', 'sync-status'], - queryFn: async () => { - try { - return validateResponse( - dingTalkSyncStatusSchema, - await api.get('/attendance-records/dingtalk-sync-status'), - ); - } catch { - return null; - } - }, + queryFn: async () => + validateResponse( + dingTalkSyncStatusSchema, + await api.get('/attendance-records/dingtalk-sync-status'), + ), }); const loadSyncStatus = useCallback(() => refetchSyncStatus(), [refetchSyncStatus]); const { data: recordQuery = { records: [], total: 0, summary: EMPTY_SUMMARY }, isFetching: recordsFetching, + isError: recordsError, refetch: refetchRecords, } = useQuery<{ records: AttendanceRecordItem[]; @@ -240,32 +230,27 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }>({ queryKey: recordQueryKey, queryFn: async () => { - try { - const [recordData, summaryData] = await Promise.all([ - api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { - params: buildParams(true), - }), - api.get('/attendance-records/summary', { - params: buildParams(false), - }), - ]); - const validatedRecords = validateResponse<{ - list: AttendanceRecordItem[]; - total: number; - }>(attendanceRecordsResponseSchema, recordData); - const validatedSummary = validateResponse( - attendanceSummarySchema, - summaryData, - ); - return { - records: validatedRecords.list, - total: validatedRecords.total, - summary: { ...EMPTY_SUMMARY, ...validatedSummary }, - }; - } catch (error: unknown) { - message.error(getErrorMessage(error, '加载学生考勤失败')); - return { records: [], total: 0, summary: EMPTY_SUMMARY }; - } + const [recordData, summaryData] = await Promise.all([ + api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { + params: buildParams(true), + }), + api.get('/attendance-records/summary', { + params: buildParams(false), + }), + ]); + const validatedRecords = validateResponse<{ + list: AttendanceRecordItem[]; + total: number; + }>(attendanceRecordsResponseSchema, recordData); + const validatedSummary = validateResponse( + attendanceSummarySchema, + summaryData, + ); + return { + records: validatedRecords.list, + total: validatedRecords.total, + summary: { ...EMPTY_SUMMARY, ...validatedSummary }, + }; }, }); const records = recordQuery.records; @@ -532,27 +517,35 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit alerts={alerts} /> - { - setPage(nextPage); - setPageSize(nextPageSize); - }} - /> + {recordsError ? ( + void refetchRecords()} + /> + ) : ( + { + setPage(nextPage); + setPageSize(nextPageSize); + }} + /> + )} = ({ c const { data: workspace, - isLoading, isFetching, + isPending, + isError, refetch, } = useQuery({ queryKey: ['attendance', 'workspace'], queryFn: async () => { - try { - return await api.get('/rbac/teacher-workspace'); - } catch (error: unknown) { - message.error(getErrorMessage(error, '加载今日课程失败')); - return null; - } + return await api.get('/rbac/teacher-workspace'); }, }); - const loading = isLoading || isFetching; + // isPending 覆盖自动重试的退避窗口,避免「加载失败/重试中」短暂闪现为空态 + const loading = isPending || isFetching; const loadWorkspace = useCallback(() => refetch(), [refetch]); const classNameById = useMemo( @@ -124,7 +120,15 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c - {schedules.length === 0 ? ( + {isError ? ( + + void loadWorkspace()} + /> + + ) : schedules.length === 0 ? ( { data: fetchResult = { devices: [], classrooms: [] }, isLoading, isFetching, + isError, + refetch, } = useQuery<{ devices: AttendanceDeviceRow[]; classrooms: ClassroomOption[] }>({ queryKey: ['attendance-devices'], queryFn: async () => { - try { - const [devices, classroomList] = await Promise.all([ - api.get('/attendance-devices'), - api.get('/classrooms'), - ]); - return { - devices: validateResponse(attendanceDevicesSchema, devices), - classrooms: validateResponse( - classroomOptionsSchema, - classroomList, - ).filter((item: any) => item.status !== 'archived'), - }; - } catch (error: any) { - message.error(error?.message || '加载考勤机绑定失败'); - return { devices: [], classrooms: [] }; - } + const [devices, classroomList] = await Promise.all([ + api.get('/attendance-devices'), + api.get('/classrooms'), + ]); + return { + devices: validateResponse(attendanceDevicesSchema, devices), + classrooms: validateResponse( + classroomOptionsSchema, + classroomList, + ).filter((item: any) => item.status !== 'archived'), + }; }, }); const data = fetchResult.devices; @@ -307,14 +305,22 @@ const AttendanceDevicesPage: React.FC = () => { 添加考勤机 - - rowKey="id" - columns={columns} - dataSource={filteredData} - loading={loading} - locale={{ emptyText: }} - pagination={{ defaultPageSize: 20, showSizeChanger: true }} - /> + {isError ? ( + void refetch()} + /> + ) : ( + + rowKey="id" + columns={columns} + dataSource={filteredData} + loading={loading} + locale={{ emptyText: }} + pagination={{ defaultPageSize: 20, showSizeChanger: true }} + /> + )} { const [saving, setSaving] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [batchLoading, setBatchLoading] = useState(false); + // 生成账单成功后的「下一步」引导提示 + const [billGeneratedHint, setBillGeneratedHint] = useState(false); const { data: bills = [], isLoading, isFetching, + isError, + refetch, } = useQuery({ queryKey: ['bills', filterStatus, filterExpenseType], queryFn: async () => { - try { - const params: Record = {}; - if (filterStatus) params.status = filterStatus; - if (filterExpenseType) params.expenseType = filterExpenseType; - return validateResponse(billsSchema, await api.get('/bills', { params })); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - return []; - } + const params: Record = {}; + if (filterStatus) params.status = filterStatus; + if (filterExpenseType) params.expenseType = filterExpenseType; + return validateResponse(billsSchema, await api.get('/bills', { params })); }, }); const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新账单列表 + useVisibleRefetch(['bills']); const generateMutation = useApiMutation( async (payload: { operationId: string; billingMonth: string }) => @@ -122,8 +126,8 @@ const BillsPage: React.FC = () => { }, [bills, searchText, filterStatus]); const handleGenerate = async () => { - setSaving(true); const values = await generateForm.validateFields(); + setSaving(true); try { const res: any = await generateMutation.mutateAsync({ operationId: newOperationId(), @@ -132,6 +136,7 @@ const BillsPage: React.FC = () => { message.success(res.message || '生成成功'); setGenerateModal(false); generateForm.resetFields(); + setBillGeneratedHint(true); } catch { // 错误提示由 useApiMutation 统一处理 } finally { @@ -470,19 +475,42 @@ const BillsPage: React.FC = () => { - `共 ${total} 条` }} - locale={{ emptyText: }} - rowSelection={{ - selectedRowKeys: selectedRows, - onChange: (keys) => setSelectedRows(keys as number[]), - }} - /> + {billGeneratedHint && ( + { + // 账单生成后即为 unpaid(待支付)状态 + setFilterStatus('unpaid'); + setBillGeneratedHint(false); + }, + }} + onClose={() => setBillGeneratedHint(false)} + /> + )} + {isError ? ( + void refetch()} + /> + ) : ( +
`共 ${total} 条` }} + locale={{ emptyText: }} + rowSelection={{ + selectedRowKeys: selectedRows, + onChange: (keys) => setSelectedRows(keys as number[]), + }} + /> + )} { data: detailResult = { detail: null, students: [], teachers: [] }, isLoading: detailLoading, isFetching: detailFetching, + isError: detailError, refetch: refetchDetail, } = useQuery<{ detail: ClassDetail | null; @@ -59,13 +61,8 @@ const ClassDetailPage: React.FC = () => { }>({ queryKey: ['classes', 'detail', id], queryFn: async () => { - try { - const res = (await api.get(`/classes/${id}`)) as ClassDetail; - return { detail: res, students: res.students || [], teachers: res.teachers || [] }; - } catch (e: unknown) { - message.error(getErrorMessage(e, '加载失败')); - return { detail: null, students: [], teachers: [] }; - } + const res = (await api.get(`/classes/${id}`)) as ClassDetail; + return { detail: res, students: res.students || [], teachers: res.teachers || [] }; }, }); const detail = detailResult.detail; @@ -74,52 +71,50 @@ const ClassDetailPage: React.FC = () => { const loading = detailLoading || detailFetching; const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]); - const { data: allUsers = [], refetch: refetchUsers } = useQuery({ + const { + data: allUsers = [], + isError: allUsersError, + refetch: refetchUsers, + } = useQuery({ queryKey: ['rbac', 'users', 'all'], queryFn: async () => { - try { - return (await api.get('/rbac/users')) as TeacherCandidateUser[]; - } catch { - return []; - } + return (await api.get('/rbac/users')) as TeacherCandidateUser[]; }, }); const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]); - const { data: schedules = [] } = useQuery({ + const { + data: schedules = [], + isError: schedulesError, + refetch: refetchSchedules, + } = useQuery({ queryKey: ['classes', 'schedule', id, scheduleDateRange], queryFn: async () => { if (!id) return []; - try { - const params: Record = {}; - if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD'); - if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD'); - return (await api.get(`/classes/${id}/schedule`, { params })) || []; - } catch (e: unknown) { - message.error(getErrorMessage(e, '加载课表失败')); - return []; - } + const params: Record = {}; + if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD'); + if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD'); + return (await api.get(`/classes/${id}/schedule`, { params })) || []; }, }); - const { data: attendanceSummary = null } = useQuery({ + const { + data: attendanceSummary = null, + isError: attendanceSummaryError, + refetch: refetchAttendanceSummary, + } = useQuery({ queryKey: ['classes', 'attendance-summary', id, attendanceDateRange], queryFn: async () => { if (!id) return null; - try { - const params: Record = {}; - if (attendanceDateRange?.[0]) - params.startDate = attendanceDateRange[0].format('YYYY-MM-DD'); - if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD'); - return ( - (await api.get(`/classes/${id}/attendance-summary`, { - params, - })) || null - ); - } catch (e: unknown) { - message.error(getErrorMessage(e, '加载出勤汇总失败')); - return null; - } + const params: Record = {}; + if (attendanceDateRange?.[0]) + params.startDate = attendanceDateRange[0].format('YYYY-MM-DD'); + if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD'); + return ( + (await api.get(`/classes/${id}/attendance-summary`, { + params, + })) || null + ); }, }); @@ -221,7 +216,25 @@ const ClassDetailPage: React.FC = () => { const getTeacherName = (teacher: ClassTeacher) => allUsers.find((user) => user.id === teacher.userId)?.name?.trim() || '-'; - if (!detail) return null; + if (!detail) { + if (loading) { + return ( +
+ +
+ ); + } + if (detailError) { + return ( + void refetchDetail()} + /> + ); + } + return null; + } return ( { { key: 'teachers', label: `教师 (${teachers.length})`, - children: ( + children: allUsersError ? ( + void fetchUsers()} + /> + ) : ( { { key: 'schedule', label: '课表', - children: ( + children: schedulesError ? ( + void refetchSchedules()} + /> + ) : ( { { key: 'attendance-summary', label: '出勤汇总', - children: ( + children: attendanceSummaryError ? ( + void refetchAttendanceSummary()} + /> + ) : ( { data = [], isLoading, isFetching, + isError, + refetch, } = useQuery({ queryKey: ['classes', filterStatus, filterType, showArchived], queryFn: async () => { - try { - const params: Record = {}; - if (filterStatus) params.status = filterStatus; - if (filterType) params.classType = filterType; - params.isArchived = showArchived; - return validateResponse( - classesSchema, - await api.get('/classes', { params } as Record), - ); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - return []; - } + const params: Record = {}; + if (filterStatus) params.status = filterStatus; + if (filterType) params.classType = filterType; + params.isArchived = showArchived; + return validateResponse( + classesSchema, + await api.get('/classes', { params } as Record), + ); }, }); const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据 + useVisibleRefetch(['classes']); const saveMutation = useApiMutation( async (payload: Record) => @@ -430,19 +431,27 @@ const ClassesPage: React.FC = () => { /> - - columns={columns} - dataSource={filtered} - rowKey="id" - loading={loading} - locale={{ emptyText: }} - pagination={{ - defaultPageSize: 20, - showSizeChanger: true, - pageSizeOptions: [20, 50, 100], - }} - scroll={{ x: 1100 }} - /> + {isError ? ( + void refetch()} + /> + ) : ( + + columns={columns} + dataSource={filtered} + rowKey="id" + loading={loading} + locale={{ emptyText: }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + }} + scroll={{ x: 1100 }} + /> + )} { data = [], isLoading, isFetching, + isError, + refetch, } = useQuery({ queryKey: ['classroom-rentals', filterMonth], queryFn: async () => { - try { - const params: any = {}; - if (filterMonth) params.month = filterMonth.format('YYYY-MM'); - params.includeEnded = true; - return validateResponse( - rentalsSchema, - await api.get('/classroom-rentals', { params }), - ); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - return []; - } + const params: any = {}; + if (filterMonth) params.month = filterMonth.format('YYYY-MM'); + params.includeEnded = true; + return validateResponse( + rentalsSchema, + await api.get('/classroom-rentals', { params }), + ); }, }); const { @@ -93,6 +92,8 @@ const ClassroomRentalsPage: React.FC = () => { const classrooms = meta.classrooms; const organizations = meta.organizations; const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据 + useVisibleRefetch(['classroom-rentals']); const saveMutation = useApiMutation( async (payload: Record) => @@ -399,22 +400,30 @@ const ClassroomRentalsPage: React.FC = () => { 新增租赁 - + {isError ? ( + void refetch()} + /> + ) : ( + + )} { const [month, setMonth] = useState(dayjs()); const [detailModal, setDetailModal] = useState(null); - const { data, isLoading, isFetching } = useQuery({ + const { data, isLoading, isFetching, isError, refetch } = useQuery({ queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()], - queryFn: async () => { - try { - return validateResponse( - classroomScheduleSchema, - await api.get('/classroom-rentals/schedule', { - params: { year: month.year(), month: month.month() + 1 }, - }), - ); - } catch (e: unknown) { - message.error(getErrorMessage(e, '加载失败,请稍后重试')); - return null; - } - }, + queryFn: async () => + validateResponse( + classroomScheduleSchema, + await api.get('/classroom-rentals/schedule', { + params: { year: month.year(), month: month.month() + 1 }, + }), + ), }); const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新排期数据 + useVisibleRefetch(['classroom-rentals', 'schedule']); // 按楼栋+楼层分组教室 const groups = useMemo(() => { @@ -135,208 +133,218 @@ const ClassroomSchedulePage: React.FC = () => { - {/* 统计卡片 */} - -
- - - - - - - - - - - - - - - - - 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600', - }, - }} - /> - - - + {isError ? ( + void refetch()} + /> + ) : ( + <> + {/* 统计卡片 */} + + + + + + + + + + + + + + + + + + + 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600', + }, + }} + /> + + + - {/* 图例 */} - {data && ( - - - 内部排课 - {data.organizations.map((t) => ( - - {t.name} (租赁) - - ))} - - 空闲 - - - - )} + {/* 图例 */} + {data && ( + + + 内部排课 + {data.organizations.map((t) => ( + + {t.name} (租赁) + + ))} + + 空闲 + + + + )} - - {!data || data.classrooms.length === 0 ? ( - - ) : ( -
- {groups.map((group) => ( - -
- - - - - - {Array.from({ length: data.days }, (_, i) => i + 1).map((d) => ( - - ))} - - - - {group.classrooms.map((c) => { - const sum = data.summary[c.id] || { - rentedDays: 0, - totalDays: data.days, - occupancyRate: 0, - }; - return ( - - + {Array.from({ length: data.days }, (_, i) => i + 1).map((d) => { + const cell = data.matrix[c.id]?.[d]; + const isInternal = cell?.scheduleType === 'INTERNAL'; + const isRental = cell?.scheduleType === 'RENTAL'; + return ( + + ); + })} + + ); + })} + +
- 教室 - - 类型 - - 占用率 - - {d} -
+ {!data || data.classrooms.length === 0 ? ( + + ) : ( +
+ {groups.map((group) => ( + + + + + - - {Array.from({ length: data.days }, (_, i) => i + 1).map((d) => { - const cell = data.matrix[c.id]?.[d]; - const isInternal = cell?.scheduleType === 'INTERNAL'; - const isRental = cell?.scheduleType === 'RENTAL'; - return ( + 教室 + + + + {Array.from({ length: data.days }, (_, i) => i + 1).map((d) => ( + + ))} + + + + {group.classrooms.map((c) => { + const sum = data.summary[c.id] || { + rentedDays: 0, + totalDays: data.days, + occupancyRate: 0, + }; + return ( + + - ); - })} - - ); - })} - -
- {c.name} - - - {c.roomType} - 0.7 - ? '#cf1322' - : sum.occupancyRate > 0.4 - ? '#fa8c16' - : '#3f8600', - }} - > - {Math.round(sum.occupancyRate * 100)}% - + 类型 + + 占用率 + + {d} +
{ - if (isRental) showDetail(cell.rentalId); - }} style={{ - padding: 0, + position: 'sticky', + left: 0, + background: '#fff', + zIndex: 1, + padding: '6px 8px', + border: '1px solid #f0f0f0', + fontWeight: 500, + }} + > + {c.name} + - {cell && ( - - - {isInternal ? ( - - ) : cell.hasContract ? ( - - ) : ( - '' - )} - - - )} + {c.roomType}
-
- ))} -
- )} - +
0.7 + ? '#cf1322' + : sum.occupancyRate > 0.4 + ? '#fa8c16' + : '#3f8600', + }} + > + {Math.round(sum.occupancyRate * 100)}% + { + if (isRental) showDetail(cell.rentalId); + }} + style={{ + padding: 0, + border: '1px solid #f0f0f0', + background: cell?.color || '#fff', + height: 26, + cursor: isRental ? 'pointer' : 'default', + textAlign: 'center', + }} + > + {cell && ( + + + {isInternal ? ( + + ) : cell.hasContract ? ( + + ) : ( + '' + )} + + + )} +
+
+ ))} + + )} +
+ + )} = { available: { text: '可用', color: 'green' }, @@ -70,21 +72,19 @@ const ClassroomsPage: React.FC = () => { data = [], isLoading, isFetching, + isError, + refetch, } = useQuery({ queryKey: ['classrooms', showArchived], - queryFn: async () => { - try { - return validateResponse( - classroomsSchema, - await api.get('/classrooms', { params: { includeArchived: showArchived } }), - ); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - return []; - } - }, + queryFn: async () => + validateResponse( + classroomsSchema, + await api.get('/classrooms', { params: { includeArchived: showArchived } }), + ), }); const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据 + useVisibleRefetch(['classrooms']); const saveMutation = useApiMutation( async (values: Record) => @@ -514,20 +514,28 @@ const ClassroomsPage: React.FC = () => { - }} - pagination={{ - defaultPageSize: 20, - showSizeChanger: true, - pageSizeOptions: [20, 50, 100], - showTotal: (total) => `共 ${total} 条`, - }} - /> + {isError ? ( + void refetch()} + /> + ) : ( +
}} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} + /> + )} { }>({ queryKey: ['dashboard', period], queryFn: async () => { - try { - const [s, rr, cr, g, co, cu] = await Promise.all([ - api.get('/dashboard/stats'), - api.get>('/dashboard/room-ranking', { - params: { periodStart: period[0], periodEnd: period[1] }, - }), - api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>( - '/dashboard/class-attendance-ranking', - ), - api.get('/dashboard/gantt', { - params: { periodStart: period[0], periodEnd: period[1] }, - }), - api.get('/dashboard/classroom-occupancy'), - api.get('/dashboard/classroom-utilization'), - ]); - return { - stats: validateResponse(dashboardStatsSchema, s), - roomRanking: validateResponse>( + // 各数据接口独立加载:单个接口失败只影响对应模块,避免整页数据被清零 + const settled = await Promise.allSettled([ + api.get('/dashboard/stats'), + api.get>('/dashboard/room-ranking', { + params: { periodStart: period[0], periodEnd: period[1] }, + }), + api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>( + '/dashboard/class-attendance-ranking', + ), + api.get('/dashboard/gantt', { + params: { periodStart: period[0], periodEnd: period[1] }, + }), + api.get('/dashboard/classroom-occupancy'), + api.get('/dashboard/classroom-utilization'), + ]); + const value = (r: PromiseSettledResult): T | null => + r.status === 'fulfilled' ? r.value : null; + const rejected = settled.filter((r) => r.status === 'rejected'); + if (rejected.length === settled.length) { + // 全部失败:抛出让 react-query 自动重试 + console.error('看板数据加载失败', rejected); + throw new Error('看板数据加载失败'); + } + if (rejected.length > 0) { + console.error('部分看板数据加载失败', rejected); + message.warning(`有 ${rejected.length} 项数据加载失败,其余数据已正常显示`); + } + let stats: DashboardStats | null = null; + const s = value(settled[0]); + if (s) { + try { + stats = validateResponse(dashboardStatsSchema, s); + } catch (e) { + console.error(e); + } + } + let roomRanking: Array<{ roomNumber: string; total: string }> = []; + const rr = value(settled[1]); + if (rr) { + try { + roomRanking = validateResponse>( roomRankingSchema, rr, - ), - classRanking: validateResponse<{ + ); + } catch (e) { + console.error(e); + } + } + let classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] } = { + top: [], + bottom: [], + }; + const cr = value(settled[2]); + if (cr) { + try { + classRanking = validateResponse<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[]; - }>(classAttendanceRankingSchema, cr), - ganttData: validateResponse(ganttRoomsSchema, g), - classroomOccupancy: validateResponse( + }>(classAttendanceRankingSchema, cr); + } catch (e) { + console.error(e); + } + } + let ganttData: GanttRoom[] = []; + const g = value(settled[3]); + if (g) { + try { + ganttData = validateResponse(ganttRoomsSchema, g); + } catch (e) { + console.error(e); + } + } + let classroomOccupancy: ClassroomOccupancy[] = []; + const co = value(settled[4]); + if (co) { + try { + classroomOccupancy = validateResponse( classroomOccupanciesSchema, co, - ), - classroomUtil: validateResponse(classroomUtilStatsSchema, cu), - }; - } catch (e) { - console.error(e); - message.error('数据加载失败,请稍后重试'); - return { - stats: null, - classRanking: { top: [], bottom: [] }, - classroomOccupancy: [], - ganttData: [], - roomRanking: [], - classroomUtil: null, - }; + ); + } catch (e) { + console.error(e); + } } + let classroomUtil: ClassroomUtilStats | null = null; + const cu = value(settled[5]); + if (cu) { + try { + classroomUtil = validateResponse(classroomUtilStatsSchema, cu); + } catch (e) { + console.error(e); + } + } + return { stats, classRanking, classroomOccupancy, ganttData, roomRanking, classroomUtil }; }, }); const stats = fetchResult.stats; diff --git a/apps/admin/src/pages/Deposits/DepositModals.tsx b/apps/admin/src/pages/Deposits/DepositModals.tsx index 9a78039a..2db7528c 100644 --- a/apps/admin/src/pages/Deposits/DepositModals.tsx +++ b/apps/admin/src/pages/Deposits/DepositModals.tsx @@ -408,6 +408,7 @@ export const DepositModals: React.FC = ({ onOk={onAddInstallment} onCancel={onCloseInstallment} okText="确认" + confirmLoading={saving} >
diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 20358bd5..f05b9ced 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -28,6 +28,8 @@ import { } from './DepositModals'; import type { DepositRecord, EligibleStudent } from './DepositModals'; import { DepositTable } from './DepositTable'; +import { QueryErrorState } from '../../components/QueryState'; +import { useVisibleRefetch } from '../../hooks/usePageVisible'; const DepositsPage: React.FC = () => { const { hasPermission } = usePermission(); @@ -55,27 +57,26 @@ const DepositsPage: React.FC = () => { data: fetchResult = { data: [], students: [] }, isLoading, isFetching, + isError, + refetch, } = useQuery<{ data: DepositRecord[]; students: DepositStudentLookup[] }>({ queryKey: ['deposits'], queryFn: async () => { - try { - const [d, s] = await Promise.all([ - api.get('/deposits'), - api.get('/deposits/student-lookups'), - ]); - return { - data: validateResponse(depositsSchema, d), - students: validateResponse(depositStudentLookupsSchema, s), - }; - } catch (e: any) { - message.error(e?.message || '加载失败'); - return { data: [], students: [] }; - } + const [d, s] = await Promise.all([ + api.get('/deposits'), + api.get('/deposits/student-lookups'), + ]); + return { + data: validateResponse(depositsSchema, d), + students: validateResponse(depositStudentLookupsSchema, s), + }; }, }); const data = fetchResult.data; const students = fetchResult.students; const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新押金列表 + useVisibleRefetch(['deposits']); const invalidateDeposits: QueryKey[] = [['deposits'], ['deposits', 'eligible']]; const createMutation = useApiMutation( @@ -132,6 +133,8 @@ const DepositsPage: React.FC = () => { const { data: eligibleStudents = [], isFetching: eligibleFetching, + isError: eligibleError, + refetch: refetchEligible, } = useQuery({ queryKey: ['deposits', 'eligible', eligibleRoomType], queryFn: async () => { @@ -224,6 +227,9 @@ const DepositsPage: React.FC = () => { const handleBatchRoomTypeChange = (roomType: string) => { setBatchRoomType(roomType); + // 切换房型后候选学生列表会变化,重置勾选状态,避免把上一房型的选择提交到新房型 + setSelectionTouched(false); + setSelectedEligibleStudentIds([]); batchForm.setFieldsValue({ amount: suggestedDepositByRoomType[roomType] ?? batchForm.getFieldValue('amount') ?? 100, }); @@ -231,6 +237,7 @@ const DepositsPage: React.FC = () => { }; const handleCreate = async () => { + setSaving(true); try { const values = await createForm.validateFields(); await createMutation.mutateAsync({ @@ -244,10 +251,13 @@ const DepositsPage: React.FC = () => { createForm.resetFields(); } catch { // 错误提示由 useApiMutation 统一处理 + } finally { + setSaving(false); } }; const handleBatchCreate = async () => { + setSaving(true); try { const values = await batchForm.validateFields(); await batchCreateMutation.mutateAsync({ @@ -263,6 +273,8 @@ const DepositsPage: React.FC = () => { setSelectionTouched(false); } catch { // 错误提示由 useApiMutation 统一处理 + } finally { + setSaving(false); } }; @@ -290,6 +302,7 @@ const DepositsPage: React.FC = () => { const handleAddInstallment = async () => { if (installmentModal == null) return; + setSaving(true); try { const values = await installmentForm.validateFields(); await addInstallmentMutation.mutateAsync({ @@ -304,6 +317,8 @@ const DepositsPage: React.FC = () => { installmentForm.resetFields(); } catch { // 错误提示由 useApiMutation 统一处理 + } finally { + setSaving(false); } }; @@ -426,16 +441,30 @@ const DepositsPage: React.FC = () => { - setDetailModal(record)} - onRefund={(record) => setRefundModal(record)} - onArchive={(id) => archiveMutation.mutateAsync(id)} - onPurge={(id) => purgeMutation.mutateAsync(id)} - /> + {isError ? ( + void refetch()} + /> + ) : filterRoomType && eligibleError ? ( + void refetchEligible()} + /> + ) : ( + setDetailModal(record)} + onRefund={(record) => setRefundModal(record)} + onArchive={(id) => archiveMutation.mutateAsync(id)} + onPurge={(id) => purgeMutation.mutateAsync(id)} + /> + )} { const { modal } = App.useApp(); @@ -76,7 +78,7 @@ const ExamsPage: React.FC = () => { }, }); - const { data = [], isFetching } = useQuery({ + const { data = [], isFetching, isError, refetch } = useQuery({ queryKey: [ 'exams', debouncedFilters.keyword, @@ -85,26 +87,23 @@ const ExamsPage: React.FC = () => { debouncedFilters.showArchived, ], queryFn: async () => { - try { - const params = new URLSearchParams(); - if (debouncedFilters.keyword.trim()) - params.set('keyword', debouncedFilters.keyword.trim()); - if (debouncedFilters.examType) params.set('examType', debouncedFilters.examType); - if (debouncedFilters.classId) params.set('classId', String(debouncedFilters.classId)); - params.set('isArchived', String(debouncedFilters.showArchived)); - return ( - validateResponse( - examsSchema, - await api.get(`/exams?${params.toString()}`), - ) ?? [] - ); - } catch (error) { - message.error(getErrorMessage(error, '加载考试失败')); - return []; - } + const params = new URLSearchParams(); + if (debouncedFilters.keyword.trim()) + params.set('keyword', debouncedFilters.keyword.trim()); + if (debouncedFilters.examType) params.set('examType', debouncedFilters.examType); + if (debouncedFilters.classId) params.set('classId', String(debouncedFilters.classId)); + params.set('isArchived', String(debouncedFilters.showArchived)); + return ( + validateResponse( + examsSchema, + await api.get(`/exams?${params.toString()}`), + ) ?? [] + ); }, }); const loading = isFetching; + // RouteKeeper 保活页面切回时刷新考试列表 + useVisibleRefetch(['exams']); const saveMutation = useApiMutation( async (payload: Record) => api.post('/exams', payload), @@ -343,7 +342,13 @@ const ExamsPage: React.FC = () => { - {data.length === 0 && !loading ? ( + {isError ? ( + void refetch()} + /> + ) : data.length === 0 && !loading ? (
diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index e8d9dff6..ffef1fb4 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -18,6 +18,8 @@ import { import { archiveViewPolicy, expenseStatusForView } from '../archive-view'; import { ExpenseTablePanel } from './ExpenseTablePanel'; import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals'; +import { QueryErrorState } from '../../components/QueryState'; +import { useVisibleRefetch } from '../../hooks/usePageVisible'; const ExpensesPage: React.FC = () => { const { modal } = App.useApp(); @@ -64,6 +66,8 @@ const ExpensesPage: React.FC = () => { data: expenseResult = { rooms: [], personal: [], students: [], roomsList: [] }, isLoading, isFetching, + isError, + refetch, } = useQuery<{ rooms: any[]; personal: any[]; @@ -72,27 +76,22 @@ const ExpensesPage: React.FC = () => { }>({ queryKey: ['expenses', showArchived ? 'archived' : 'active'], queryFn: async () => { - try { - const [rooms, personal, students, roomsList] = await Promise.all([ - api.get('/expenses/room', { - params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, - }), - api.get('/expenses/personal', { - params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, - }), - api.get('/expenses/student-lookups'), - api.get('/rooms'), - ]); - return { - rooms: validateResponse(expenseRecordsSchema, rooms), - personal: validateResponse(expenseRecordsSchema, personal), - students: validateResponse(expenseStudentLookupsSchema, students), - roomsList: validateResponse(expenseRoomsListSchema, roomsList), - }; - } catch { - message.error('加载费用数据失败'); - return { rooms: [], personal: [], students: [], roomsList: [] }; - } + const [rooms, personal, students, roomsList] = await Promise.all([ + api.get('/expenses/room', { + params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, + }), + api.get('/expenses/personal', { + params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, + }), + api.get('/expenses/student-lookups'), + api.get('/rooms'), + ]); + return { + rooms: validateResponse(expenseRecordsSchema, rooms), + personal: validateResponse(expenseRecordsSchema, personal), + students: validateResponse(expenseStudentLookupsSchema, students), + roomsList: validateResponse(expenseRoomsListSchema, roomsList), + }; }, }); const roomExpenses = expenseResult.rooms; @@ -100,6 +99,8 @@ const ExpensesPage: React.FC = () => { const students = expenseResult.students; const rooms = expenseResult.roomsList; const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新费用列表 + useVisibleRefetch(['expenses']); const mutations = { saveRoom: useApiMutation( @@ -456,103 +457,111 @@ const ExpensesPage: React.FC = () => { 已归档费用 - { - try { - await mutations.period.mutateAsync({ id, periodStart, periodEnd }); - message.success('已保存'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }} - onEdit={openEditRoom} - onArchive={(id) => mutations.archiveRoom.mutateAsync(id)} - onPurge={handlePurgeRoom} - onImport={(formData) => mutations.importUtility.mutateAsync(formData)} - onTemplateDownload={() => { - void downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch( - () => message.error('下载失败'), - ); - }} - onAddUtility={() => setUtilityModal(true)} - /> - ), - }, - { - key: 'personal', - label: '个人附加费', - children: ( - undefined} - onEdit={openEditPersonal} - onArchive={(id) => mutations.archivePersonal.mutateAsync(id)} - onPurge={handlePurgePersonal} - onImport={(formData) => mutations.importPersonal.mutateAsync(formData)} - onTemplateDownload={() => { - void downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch( - () => message.error('下载失败'), - ); - }} - onExport={() => { - downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() => - message.error('导出失败'), - ); - }} - /> - ), - }, - ]} - /> + {isError ? ( + void refetch()} + /> + ) : ( + { + try { + await mutations.period.mutateAsync({ id, periodStart, periodEnd }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }} + onEdit={openEditRoom} + onArchive={(id) => mutations.archiveRoom.mutateAsync(id)} + onPurge={handlePurgeRoom} + onImport={(formData) => mutations.importUtility.mutateAsync(formData)} + onTemplateDownload={() => { + void downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch( + () => message.error('下载失败'), + ); + }} + onAddUtility={() => setUtilityModal(true)} + /> + ), + }, + { + key: 'personal', + label: '个人附加费', + children: ( + undefined} + onEdit={openEditPersonal} + onArchive={(id) => mutations.archivePersonal.mutateAsync(id)} + onPurge={handlePurgePersonal} + onImport={(formData) => mutations.importPersonal.mutateAsync(formData)} + onTemplateDownload={() => { + void downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch( + () => message.error('下载失败'), + ); + }} + onExport={() => { + downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() => + message.error('导出失败'), + ); + }} + /> + ), + }, + ]} + /> + )} { const { modal } = App.useApp(); + const navigate = useNavigate(); const { hasPermission, permissionsReady } = usePermission(); const canCheckIn = permissionsReady && hasPermission('occupancy:checkin'); const canCheckOut = permissionsReady && hasPermission('occupancy:checkout'); @@ -53,6 +58,8 @@ const OccupanciesPage: React.FC = () => { const [checkInModal, setCheckInModal] = useState(false); const [checkOutModal, setCheckOutModal] = useState(null); const [transferModal, setTransferModal] = useState(null); + // 入住成功后的「下一步」引导提示 + const [nextStepHint, setNextStepHint] = useState<'billing' | null>(null); const [viewMode, setViewMode] = useState('active'); const viewPolicy = occupancyViewPolicy(viewMode); const [autoDeposit, setAutoDeposit] = useState(true); @@ -75,46 +82,44 @@ const OccupanciesPage: React.FC = () => { data: fetchResult = { data: [], students: [], rooms: [] }, isLoading, isFetching, + isError, + refetch, } = useQuery<{ data: OccupancyRow[]; students: StudentLookupRow[]; rooms: RoomOverviewRow[] }>({ queryKey: ['occupancies', viewMode, dateRange], queryFn: async () => { - try { - const [occRes, stuRes, rmRes] = await Promise.allSettled([ - api.get('/occupancies', { - params: { - ...occupancyParamsForView(viewMode), - dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), - dateTo: dateRange?.[1]?.format('YYYY-MM-DD'), - }, - }), - api.get('/students/basic-lookups'), - api.get('/rooms/overview'), - ]); - const labels = ['入住数据', '学生列表', '房间列表']; - [occRes, stuRes, rmRes].forEach((res, i) => { - if (res.status === 'rejected') { - message.warning(`${labels[i]}加载失败`); - } - }); - return { - data: - occRes.status === 'fulfilled' - ? validateResponse(occupanciesSchema, occRes.value) - : [], - students: stuRes.status === 'fulfilled' ? stuRes.value : [], - rooms: rmRes.status === 'fulfilled' ? rmRes.value : [], - }; - } catch (e) { - console.error(e); - message.error('数据加载异常'); - return { data: [], students: [], rooms: [] }; + const [occRes, stuRes, rmRes] = await Promise.allSettled([ + api.get('/occupancies', { + params: { + ...occupancyParamsForView(viewMode), + dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), + dateTo: dateRange?.[1]?.format('YYYY-MM-DD'), + }, + }), + api.get('/students/basic-lookups'), + api.get('/rooms/overview'), + ]); + const labels = ['入住数据', '学生列表', '房间列表']; + [stuRes, rmRes].forEach((res, i) => { + if (res.status === 'rejected') { + message.warning(`${labels[i + 1]}加载失败`); + } + }); + if (occRes.status === 'rejected') { + throw occRes.reason; } + return { + data: validateResponse(occupanciesSchema, occRes.value), + students: stuRes.status === 'fulfilled' ? stuRes.value : [], + rooms: rmRes.status === 'fulfilled' ? rmRes.value : [], + }; }, }); const data = fetchResult.data; const students = fetchResult.students; const rooms = fetchResult.rooms; const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新入住列表 + useVisibleRefetch(['occupancies']); const { checkInMutation, @@ -270,6 +275,8 @@ const OccupanciesPage: React.FC = () => { message.success('入住登记成功'); setCheckInModal(false); checkInForm.resetFields(); + // 引导业务闭环的下一步:录费用 → 生成账单 + setNextStepHint('billing'); } catch { // 错误提示由 useApiMutation 统一处理 } finally { @@ -518,26 +525,42 @@ const OccupanciesPage: React.FC = () => { }); }} /> - { - batchCheckOutForm.resetFields(); - batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() }); - setBatchCheckOutModal(true); - }} - onBatchDelete={handleBatchDelete} - onBatchRestore={handleBatchRestore} - onBatchPurge={handleBatchPurge} - onClearSelection={() => setSelectedRowKeys([])} - /> + {nextStepHint === 'billing' && ( + navigate('/expenses') }} + onClose={() => setNextStepHint(null)} + /> + )} + {isError ? ( + void refetch()} + /> + ) : ( + { + batchCheckOutForm.resetFields(); + batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() }); + setBatchCheckOutModal(true); + }} + onBatchDelete={handleBatchDelete} + onBatchRestore={handleBatchRestore} + onBatchPurge={handleBatchPurge} + onClearSelection={() => setSelectedRowKeys([])} + /> + )} { ); const handleSubmit = async () => { - setSaving(true); const values = await form.validateFields(); + setSaving(true); try { await saveMutation.mutateAsync({ name: values.name, diff --git a/apps/admin/src/pages/RoomVisual/index.tsx b/apps/admin/src/pages/RoomVisual/index.tsx index fd0c3759..1c29f7ed 100644 --- a/apps/admin/src/pages/RoomVisual/index.tsx +++ b/apps/admin/src/pages/RoomVisual/index.tsx @@ -98,16 +98,11 @@ const RoomVisualPage: React.FC = () => { const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day'); const queryClient = useQueryClient(); - const { data, isLoading, isFetching } = useQuery({ + const { data, isLoading, isFetching, isError } = useQuery({ queryKey: ['rooms', 'visual', isHistorical, asOf], queryFn: async () => { - try { - const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined; - return await api.get('/rooms/visual', { params }); - } catch (e: unknown) { - message.error(getErrorMessage(e, '加载失败,请稍后重试')); - return null; - } + const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined; + return await api.get('/rooms/visual', { params }); }, }); const loading = isLoading || isFetching; @@ -141,6 +136,23 @@ const RoomVisualPage: React.FC = () => { } }; + if (isError && !data) { + return ( + + + + + ); + } + if (!data) return ; const rooms = data.rooms.filter((r: any) => { diff --git a/apps/admin/src/pages/Rooms/RoomDrawer.tsx b/apps/admin/src/pages/Rooms/RoomDrawer.tsx index f01a114c..9582a7ce 100644 --- a/apps/admin/src/pages/Rooms/RoomDrawer.tsx +++ b/apps/admin/src/pages/Rooms/RoomDrawer.tsx @@ -12,6 +12,7 @@ import { } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import PermissionButton from '../../components/PermissionButton'; +import { QueryErrorState } from '../../components/QueryState'; import { BED_STATUS_MAP, BED_STATUS_OPTIONS, @@ -26,6 +27,10 @@ export interface RoomDrawerProps { room: any; beds: BedItem[]; lockers: LockerItem[]; + bedsError: boolean; + lockersError: boolean; + onRetryBeds: () => void; + onRetryLockers: () => void; canEditRooms: boolean; remainingBedSlots: number; defaultBatchBedCount: number; @@ -47,6 +52,10 @@ export const RoomDrawer: React.FC = ({ room, beds, lockers, + bedsError, + lockersError, + onRetryBeds, + onRetryLockers, canEditRooms, remainingBedSlots, defaultBatchBedCount, @@ -145,7 +154,14 @@ export const RoomDrawer: React.FC = ({ { key: 'beds', label: `床位管理 (${beds.length})`, - children: ( + children: bedsError ? ( + + ) : (
{canEditRooms ? (
@@ -267,7 +283,14 @@ export const RoomDrawer: React.FC = ({ { key: 'lockers', label: `柜子管理 (${lockers.length})`, - children: ( + children: lockersError ? ( + + ) : (
{canEditRooms ? (
diff --git a/apps/admin/src/pages/Rooms/RoomModals.tsx b/apps/admin/src/pages/Rooms/RoomModals.tsx index f06c0b7d..f69f6ded 100644 --- a/apps/admin/src/pages/Rooms/RoomModals.tsx +++ b/apps/admin/src/pages/Rooms/RoomModals.tsx @@ -166,6 +166,10 @@ export const RoomDetailArea: React.FC<{ drawerRoom: any; beds: BedItem[]; lockers: LockerItem[]; + bedsError: boolean; + lockersError: boolean; + onRetryBeds: () => void; + onRetryLockers: () => void; canEditRooms: boolean; remainingBedSlots: number; defaultBatchBedCount: number; @@ -208,6 +212,10 @@ export const RoomDetailArea: React.FC<{ room={props.drawerRoom} beds={props.beds} lockers={props.lockers} + bedsError={props.bedsError} + lockersError={props.lockersError} + onRetryBeds={props.onRetryBeds} + onRetryLockers={props.onRetryLockers} canEditRooms={props.canEditRooms} remainingBedSlots={props.remainingBedSlots} defaultBatchBedCount={props.defaultBatchBedCount} diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index 8efb5589..1aefcd8f 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -12,8 +12,10 @@ import api from '../../api'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; +import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { selectArchiveRecords } from '../archive-view'; import { getErrorMessage } from '../../utils/error'; +import { QueryErrorState } from '../../components/QueryState'; import { useRoomColumns, type BedItem, type LockerItem } from './RoomColumns'; import { RoomDetailArea } from './RoomModals'; import { RoomsToolbar } from './RoomsToolbar'; @@ -51,28 +53,29 @@ const RoomsPage: React.FC = () => { const [savingBed, setSavingBed] = useState(false); const [savingLocker, setSavingLocker] = useState(false); const [batchLoading, setBatchLoading] = useState(false); + const [bedsError, setBedsError] = useState(false); + const [lockersError, setLockersError] = useState(false); const { data = [], isLoading, isFetching, + isError, + refetch, } = useQuery({ queryKey: ['rooms', 'overview', showArchived], queryFn: async () => { - try { - const params: any = { includeArchived: showArchived ? 'true' : undefined }; - const res: any = await api.get('/rooms/overview', { params }); - return selectArchiveRecords( - validateResponse(roomsOverviewSchema, res), - showArchived ? 'archived' : 'active', - ); - } catch (e: unknown) { - message.error(getErrorMessage(e, '加载失败,请稍后重试')); - return []; - } + const params: any = { includeArchived: showArchived ? 'true' : undefined }; + const res: any = await api.get('/rooms/overview', { params }); + return selectArchiveRecords( + validateResponse(roomsOverviewSchema, res), + showArchived ? 'archived' : 'active', + ); }, }); const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据 + useVisibleRefetch(['rooms']); const { saveMutation, @@ -223,7 +226,9 @@ const RoomsPage: React.FC = () => { try { const res = await api.get(`/rooms/${roomId}/beds`); setBeds(res); + setBedsError(false); } catch (e: unknown) { + setBedsError(true); message.error(getErrorMessage(e, '加载床位失败')); } }, []); @@ -232,7 +237,9 @@ const RoomsPage: React.FC = () => { try { const res = await api.get(`/rooms/${roomId}/lockers`); setLockers(res); + setLockersError(false); } catch (e: unknown) { + setLockersError(true); message.error(getErrorMessage(e, '加载柜子失败')); } }, []); @@ -466,13 +473,21 @@ const RoomsPage: React.FC = () => { onExport={handleExport} /> - + {isError ? ( + void refetch()} + /> + ) : ( + + )} { drawerRoom={drawerRoom} beds={beds} lockers={lockers} + bedsError={bedsError} + lockersError={lockersError} + onRetryBeds={() => { + if (drawerRoom) void fetchBeds(drawerRoom.id); + }} + onRetryLockers={() => { + if (drawerRoom) void fetchLockers(drawerRoom.id); + }} canEditRooms={canEditRooms} remainingBedSlots={remainingBedSlots} defaultBatchBedCount={defaultBatchBedCount} diff --git a/apps/admin/src/pages/Schedules/ScheduleModals.tsx b/apps/admin/src/pages/Schedules/ScheduleModals.tsx index 5a8098cd..7ed58326 100644 --- a/apps/admin/src/pages/Schedules/ScheduleModals.tsx +++ b/apps/admin/src/pages/Schedules/ScheduleModals.tsx @@ -23,6 +23,7 @@ import { import { CloudSyncOutlined, EditOutlined, PlusOutlined, StopOutlined } from '@ant-design/icons'; import type { Dayjs } from 'dayjs'; import PermissionButton from '../../components/PermissionButton'; +import { QueryErrorState } from '../../components/QueryState'; import { isMaskedSchedule } from './schedule-visibility'; import type { ScheduleFormValues } from './schedule-form'; import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem } from './ScheduleGrids'; @@ -340,6 +341,7 @@ export interface SyncModalProps { mappedClasses: number; totalClasses: number; } | null; + syncStatusError: boolean; syncResult: { scheduleCount: number; shiftCount: number; @@ -355,6 +357,7 @@ export interface SyncModalProps { syncDays: number; attendanceMachineOnly: boolean; onClose: () => void; + onRetryStatus: () => void; onSync: () => void; onDateChange: (date: Dayjs) => void; onDaysChange: (days: number) => void; @@ -365,11 +368,13 @@ export const SyncModal: React.FC = ({ open, syncing, syncStatus, + syncStatusError, syncResult, syncDateFrom, syncDays, attendanceMachineOnly, onClose, + onRetryStatus, onSync, onDateChange, onDaysChange, @@ -555,6 +560,13 @@ export const SyncModal: React.FC = ({ /> )}
+ ) : syncStatusError ? ( + ) : ( )} diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index c188c4c4..9ce4a076 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -5,7 +5,9 @@ import { CalendarOutlined, CloudSyncOutlined, LeftOutlined, RightOutlined } from import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; +import { QueryErrorState } from '../../components/QueryState'; import { usePermission } from '../../hooks/usePermission'; +import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { message } from '../../ui/app-message'; import { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; @@ -64,6 +66,7 @@ const SchedulesPage: React.FC = () => { mappedClasses: number; totalClasses: number; } | null>(null); + const [syncStatusError, setSyncStatusError] = useState(false); const [syncResult, setSyncResult] = useState(null); const [syncDateFrom, setSyncDateFrom] = useState(dayjs); const [syncDays, setSyncDays] = useState(30); @@ -73,6 +76,7 @@ const SchedulesPage: React.FC = () => { const openSyncModal = async () => { setSyncModalOpen(true); setSyncResult(null); + setSyncStatusError(false); try { const res = await api.get<{ success: boolean; @@ -80,6 +84,7 @@ const SchedulesPage: React.FC = () => { }>('/sync/schedule/status'); setSyncStatus(res.data); } catch { + setSyncStatusError(true); setSyncStatus(null); } }; @@ -154,6 +159,8 @@ const SchedulesPage: React.FC = () => { data: fetchResult = { classrooms: [], classes: [], matrix: {} }, isLoading, isFetching, + isError, + refetch, } = useQuery<{ classrooms: ClassroomItem[]; classes: ClassItem[]; @@ -161,51 +168,48 @@ const SchedulesPage: React.FC = () => { }>({ queryKey: ['class-schedules', startDateStr, endDateStr, filterClassroomIds], queryFn: async () => { - try { - const [lookups, schedulesRes] = await Promise.all([ - api.get('/class-schedules/lookups') as Promise<{ - classrooms: ClassroomItem[]; - classes: ClassItem[]; - }>, - api.get('/class-schedules/weekly', { - params: { - startDate: startDateStr, - endDate: endDateStr, - ...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}), - }, - }) as Promise>>, - ]); - const validatedLookups = validateResponse<{ + const [lookups, schedulesRes] = await Promise.all([ + api.get('/class-schedules/lookups') as Promise<{ classrooms: ClassroomItem[]; classes: ClassItem[]; - }>(scheduleLookupsSchema, lookups); - const validatedWeekly = validateResponse< - Record> - >(weeklyScheduleSchema, schedulesRes); + }>, + api.get('/class-schedules/weekly', { + params: { + startDate: startDateStr, + endDate: endDateStr, + ...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}), + }, + }) as Promise>>, + ]); + const validatedLookups = validateResponse<{ + classrooms: ClassroomItem[]; + classes: ClassItem[]; + }>(scheduleLookupsSchema, lookups); + const validatedWeekly = validateResponse< + Record> + >(weeklyScheduleSchema, schedulesRes); - const typedMatrix: Record> = {}; - for (const [cId, dayMap] of Object.entries(validatedWeekly)) { - const classroomId = Number(cId); - typedMatrix[classroomId] = {}; - for (const [wd, schedules] of Object.entries(dayMap)) { - typedMatrix[classroomId][Number(wd)] = schedules; - } + const typedMatrix: Record> = {}; + for (const [cId, dayMap] of Object.entries(validatedWeekly)) { + const classroomId = Number(cId); + typedMatrix[classroomId] = {}; + for (const [wd, schedules] of Object.entries(dayMap)) { + typedMatrix[classroomId][Number(wd)] = schedules; } - return { - classrooms: validatedLookups.classrooms, - classes: validatedLookups.classes, - matrix: typedMatrix, - }; - } catch (e: unknown) { - message.error(getErrorMessage(e, '加载排课数据失败')); - return { classrooms: [], classes: [], matrix: {} }; } + return { + classrooms: validatedLookups.classrooms, + classes: validatedLookups.classes, + matrix: typedMatrix, + }; }, }); const classrooms = fetchResult.classrooms; const classes = fetchResult.classes; const matrix = fetchResult.matrix; const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新排课数据 + useVisibleRefetch(['class-schedules']); const saveMutation = useApiMutation( async (payload: Record) => @@ -498,18 +502,26 @@ const SchedulesPage: React.FC = () => { - + {isError ? ( + void refetch()} + /> + ) : ( + + )} { open={syncModalOpen} syncing={syncing} syncStatus={syncStatus} + syncStatusError={syncStatusError} syncResult={syncResult} syncDateFrom={syncDateFrom} syncDays={syncDays} @@ -566,6 +579,7 @@ const SchedulesPage: React.FC = () => { setSyncModalOpen(false); setSyncResult(null); }} + onRetryStatus={() => void openSyncModal()} onSync={handleSyncSchedule} onDateChange={setSyncDateFrom} onDaysChange={setSyncDays} diff --git a/apps/admin/src/pages/Students/StudentsToolbar.tsx b/apps/admin/src/pages/Students/StudentsToolbar.tsx index 201e3026..c21053ba 100644 --- a/apps/admin/src/pages/Students/StudentsToolbar.tsx +++ b/apps/admin/src/pages/Students/StudentsToolbar.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { + App, Button, Input, Popconfirm, @@ -94,6 +95,7 @@ export const StudentsToolbar: React.FC = ({ onDownloadTemplate, onExport, }) => { + const { modal } = App.useApp(); return ( <>
@@ -236,7 +238,24 @@ export const StudentsToolbar: React.FC = ({ - + { + const file = options.file as File; + modal.confirm({ + title: '确认覆盖更新学生资料?', + content: `将按手机号/身份证匹配并更新「${file.name}」中的学生资料,匹配到的学生记录会被直接覆盖且无法撤销。建议先导出名单核对后再操作。`, + okText: '确认更新', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: () => { + // 实现方只使用 { file, onSuccess, onError };info 参数仅用于满足类型签名 + onUpdateImport?.(options, { defaultRequest: () => undefined }); + }, + }); + }} + > diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index c6018011..6bcb9d6f 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router'; import { App, Form, @@ -6,6 +7,9 @@ import { import api from '../../api'; import { usePermission } from '../../hooks/usePermission'; import { useUserStore } from '../../store/user/userStore'; +import { useVisibleRefetch } from '../../hooks/usePageVisible'; +import { QueryErrorState } from '../../components/QueryState'; +import { NextStepHint } from '../../components/NextStepHint'; import { selectArchiveRecords } from '../archive-view'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; @@ -59,6 +63,7 @@ interface StudentFilterLookups { const StudentsPage: React.FC = () => { const { modal } = App.useApp(); + const navigate = useNavigate(); const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission(); const canViewOrganizations = hasPermission('organization:view'); const canLoadOrganizations = hasAnyPermission( @@ -92,6 +97,8 @@ const StudentsPage: React.FC = () => { const [form] = Form.useForm(); const [saving, setSaving] = useState(false); const [jinshujuOpen, setJinshujuOpen] = useState(false); + // 导入成功后的「下一步」引导提示 + const [nextStepHint, setNextStepHint] = useState<'class' | null>(null); const openDrawer = useCallback((studentId: number) => { setDrawerStudentId(studentId); @@ -152,6 +159,8 @@ const StudentsPage: React.FC = () => { data = [], isLoading, isFetching, + isError, + refetch, } = useQuery({ queryKey: [ 'students', @@ -163,28 +172,25 @@ const StudentsPage: React.FC = () => { filterTeacherId, ], queryFn: async () => { - try { - const params: Record = { - name: searchName || undefined, - includeArchived: showArchived ? 'true' : undefined, - }; - if (showArchived) params.status = 'archived'; - else if (filterStatus) params.status = filterStatus; - if (effectiveFilterOrganizationId) params.organizationId = effectiveFilterOrganizationId; - if (filterClassId) params.classId = filterClassId; - if (filterTeacherId) params.teacherId = filterTeacherId; - const res = (await api.get('/students', { params })) as Array>; - return selectArchiveRecords( - validateResponse>>(studentsSchema, res), - showArchived ? 'archived' : 'active', - ); - } catch (e: unknown) { - message.error(getErrorMessage(e, '加载失败,请稍后重试')); - return []; - } + const params: Record = { + name: searchName || undefined, + includeArchived: showArchived ? 'true' : undefined, + }; + if (showArchived) params.status = 'archived'; + else if (filterStatus) params.status = filterStatus; + if (effectiveFilterOrganizationId) params.organizationId = effectiveFilterOrganizationId; + if (filterClassId) params.classId = filterClassId; + if (filterTeacherId) params.teacherId = filterTeacherId; + const res = (await api.get('/students', { params })) as Array>; + return selectArchiveRecords( + validateResponse>>(studentsSchema, res), + showArchived ? 'archived' : 'active', + ); }, }); const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据 + useVisibleRefetch(['students']); const queryClient = useQueryClient(); const invalidateStudents: Array = [['students']]; @@ -428,6 +434,10 @@ const StudentsPage: React.FC = () => { const res = (await importMutation.mutateAsync(formData)) as StudentCreateImportResult; showCreateImportResult(modal, res); onSuccess?.(res); + // 导入成功且产生了新学生时,提示业务闭环的下一步 + if (typeof res.imported === 'number' && res.imported > 0) { + setNextStepHint('class'); + } } catch (e) { onError?.(e instanceof Error ? e : new Error(getErrorMessage(e, '导入失败'))); } @@ -497,6 +507,8 @@ const StudentsPage: React.FC = () => { onViewSensitive: handleViewSensitive, onOpenDrawer: openDrawer, onEdit: (record) => { + // 先重置再回填,避免上一名学生的字段残留到当前表单 + form.resetFields(); setEditing(record); form.setFieldsValue(record); setModalOpen(true); @@ -570,16 +582,32 @@ const StudentsPage: React.FC = () => { onDownloadTemplate={handleDownloadTemplate} onExport={handleExport} /> - setPageInfo({ current, pageSize })} - selectedRowKeys={selectedRowKeys} - onSelect={setSelectedRowKeys} - onClearSelection={() => setSelectedRowKeys([])} - /> + {nextStepHint === 'class' && ( + navigate('/classes') }} + onClose={() => setNextStepHint(null)} + /> + )} + {isError ? ( + void refetch()} + /> + ) : ( + setPageInfo({ current, pageSize })} + selectedRowKeys={selectedRowKeys} + onSelect={setSelectedRowKeys} + onClearSelection={() => setSelectedRowKeys([])} + /> + )} { onCancel={() => { setModalOpen(false); setEditing(null); + form.resetFields(); }} /> {canSyncJinshuju ? ( diff --git a/apps/admin/src/pages/TeacherWorkspace/index.tsx b/apps/admin/src/pages/TeacherWorkspace/index.tsx index 163ea8ec..0995269d 100644 --- a/apps/admin/src/pages/TeacherWorkspace/index.tsx +++ b/apps/admin/src/pages/TeacherWorkspace/index.tsx @@ -6,7 +6,7 @@ import { teacherWorkspaceSchema } from '../../api/schemas'; import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import api from '../../api'; -import { message } from '../../ui/app-message'; +import { QueryErrorState } from '../../components/QueryState'; interface AssignedClass { classId: number; @@ -59,19 +59,13 @@ const WEEKDAY_LABELS: Record = { }; const TeacherWorkspacePage: React.FC = () => { - const { data, isLoading, isFetching } = useQuery({ + const { data, isLoading, isFetching, isError, refetch } = useQuery({ queryKey: ['rbac', 'teacher-workspace'], queryFn: async () => { - try { - return validateResponse( - teacherWorkspaceSchema, - await api.get('/rbac/teacher-workspace'), - ); - } catch (e) { - console.error(e); - message.error('数据加载失败'); - return null; - } + return validateResponse( + teacherWorkspaceSchema, + await api.get('/rbac/teacher-workspace'), + ); }, }); const loading = isLoading || isFetching; @@ -136,6 +130,16 @@ const TeacherWorkspacePage: React.FC = () => { [], ); + if (isError) { + return ( + void refetch()} + /> + ); + } + if (loading) { return (
diff --git a/apps/admin/src/pages/Teachers/index.tsx b/apps/admin/src/pages/Teachers/index.tsx index 6541de83..706f3969 100644 --- a/apps/admin/src/pages/Teachers/index.tsx +++ b/apps/admin/src/pages/Teachers/index.tsx @@ -11,6 +11,8 @@ import { message } from '../../ui/app-message'; import EditableCell from '../../components/EditableCell'; import PermissionButton from '../../components/PermissionButton'; import { usePermission } from '../../hooks/usePermission'; +import { QueryErrorState } from '../../components/QueryState'; +import { useVisibleRefetch } from '../../hooks/usePageVisible'; interface TeacherRow { id: number; @@ -68,24 +70,24 @@ const TeachersPage: React.FC = () => { data: fetchResult = { list: [], total: 0 }, isLoading, isFetching, + isError, + refetch, } = useQuery({ queryKey: ['rbac', 'teachers', page, pageSize, search], queryFn: async () => { - try { - return validateResponse( - teacherListSchema, - await api.get('/rbac/teachers', { - params: { search: search || undefined, page, pageSize }, - }), - ); - } catch { - return { list: [], total: 0 }; - } + return validateResponse( + teacherListSchema, + await api.get('/rbac/teachers', { + params: { search: search || undefined, page, pageSize }, + }), + ); }, }); const data = fetchResult.list; const total = fetchResult.total; const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新教师列表 + useVisibleRefetch(['rbac', 'teachers']); const saveProfileMutation = useApiMutation( async ({ @@ -248,39 +250,47 @@ const TeachersPage: React.FC = () => { style={{ width: 220 }} /> -
{ - setPage(nextPage); - setPageSize(nextPageSize); - }, - showTotal: (t) => `共 ${t} 人`, - }} - expandable={{ - rowExpandable: (r) => (r.classAssignments || []).length > 0, - expandedRowRender: (r) => - r.classAssignments?.length ? ( - - {r.classAssignments.map((a, i) => ( - - {ROLE_TYPE_LABELS[a.roleType] || a.roleType}: {a.className} - {a.subject ? ` — ${a.subject}` : ''} - - ))} - - ) : null, - }} - /> + {isError ? ( + void refetch()} + /> + ) : ( +
{ + setPage(nextPage); + setPageSize(nextPageSize); + }, + showTotal: (t) => `共 ${t} 人`, + }} + expandable={{ + rowExpandable: (r) => (r.classAssignments || []).length > 0, + expandedRowRender: (r) => + r.classAssignments?.length ? ( + + {r.classAssignments.map((a, i) => ( + + {ROLE_TYPE_LABELS[a.roleType] || a.roleType}: {a.className} + {a.subject ? ` — ${a.subject}` : ''} + + ))} + + ) : null, + }} + /> + )} { ); const handleSubmit = async () => { - setSaving(true); const values = await form.validateFields(); + setSaving(true); try { await saveMutation.mutateAsync({ username: values.username, @@ -211,8 +211,8 @@ const UsersPage: React.FC = () => { ); const handlePwdSubmit = async () => { - setSaving(true); const values = await pwdForm.validateFields(); + setSaving(true); try { await pwdMutation.mutateAsync({ id: resetTarget.id, password: values.password }); message.success('密码已重置'); diff --git a/apps/admin/src/pages/Wallets/index.tsx b/apps/admin/src/pages/Wallets/index.tsx index 8364f0f7..2e0469f6 100644 --- a/apps/admin/src/pages/Wallets/index.tsx +++ b/apps/admin/src/pages/Wallets/index.tsx @@ -23,6 +23,8 @@ import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; import { newOperationId } from '../../utils/operation-id'; +import { QueryErrorState } from '../../components/QueryState'; +import { useVisibleRefetch } from '../../hooks/usePageVisible'; interface WalletRow { studentId: number; @@ -54,7 +56,6 @@ const WalletsPage: React.FC = () => { const [debtOnly, setDebtOnly] = useState(false); const [roomType, setRoomType] = useState(); const [selected, setSelected] = useState(null); - const [transactions, setTransactions] = useState([]); const [drawerOpen, setDrawerOpen] = useState(false); const [form] = Form.useForm(); const [batchForm] = Form.useForm(); @@ -66,21 +67,17 @@ const WalletsPage: React.FC = () => { data: rows = [], isLoading, isFetching, + isError, refetch, } = useQuery({ queryKey: ['wallets', keyword, debtOnly, roomType], queryFn: async () => { - try { - return validateResponse( - walletsSchema, - await api.get('/wallets', { - params: { keyword: keyword || undefined, debtOnly, roomType }, - }), - ); - } catch (error: any) { - message.error(error?.message || '加载学生余额失败'); - return []; - } + return validateResponse( + walletsSchema, + await api.get('/wallets', { + params: { keyword: keyword || undefined, debtOnly, roomType }, + }), + ); }, }); const { data: roomTypes = [] } = useQuery({ @@ -98,6 +95,27 @@ const WalletsPage: React.FC = () => { }, }); const loading = isLoading || isFetching; + // RouteKeeper 保活页面切回时刷新余额,避免费用/账单操作后数据陈旧 + useVisibleRefetch(['wallets']); + + const selectedStudentId = selected?.studentId; + const { + data: transactions = [], + isLoading: txLoading, + isFetching: txFetching, + isError: txError, + refetch: refetchTransactions, + } = useQuery({ + queryKey: ['wallets', 'transactions', selectedStudentId], + enabled: drawerOpen && !!selectedStudentId, + queryFn: async () => { + return ( + (await api.get('/wallets/transactions', { + params: { studentId: selectedStudentId }, + })) || [] + ); + }, + }); const fetchRows = useCallback(() => refetch(), [refetch]); const changeMutation = useApiMutation( @@ -193,21 +211,11 @@ const WalletsPage: React.FC = () => { }; const showTransactions = useCallback( - async (row: WalletRow) => { + (row: WalletRow) => { setSelected(row); setDrawerOpen(true); - try { - setTransactions( - await api.get('/wallets/transactions', { - params: { studentId: row.studentId }, - }), - ); - } catch (error: unknown) { - console.error('加载余额流水失败', error); - message.error('加载流水失败'); - } }, - [setSelected, setDrawerOpen, setTransactions], + [setSelected, setDrawerOpen], ); const columns = useMemo( @@ -310,19 +318,27 @@ const WalletsPage: React.FC = () => { -
`共 ${total} 人`, - }} - /> + {isError ? ( + void refetch()} + /> + ) : ( +
`共 ${total} 人`, + }} + /> + )} { setSelected(null); }} > -
dayjs(value).format('YYYY-MM-DD HH:mm'), - }, - { - title: '类型', - dataIndex: 'type', - render: (value: string) => transactionNames[value] || value, - }, - { - title: '金额', - dataIndex: 'amount', - render: (value: number) => ( - = 0 ? '#389e0d' : '#cf1322' }}> - {value >= 0 ? '+' : ''}¥{value.toFixed(2)} - - ), - }, - { - title: '变动后余额', - dataIndex: 'balanceAfter', - render: (value: number) => `¥${value.toFixed(2)}`, - }, - { - title: '关联账单', - dataIndex: 'billId', - render: (value: number) => (value ? `#${value}` : '-'), - }, - { title: '说明', dataIndex: 'description' }, - ]} - /> + {txError ? ( + void refetchTransactions()} + /> + ) : ( +
dayjs(value).format('YYYY-MM-DD HH:mm'), + }, + { + title: '类型', + dataIndex: 'type', + render: (value: string) => transactionNames[value] || value, + }, + { + title: '金额', + dataIndex: 'amount', + render: (value: number) => ( + = 0 ? '#389e0d' : '#cf1322' }}> + {value >= 0 ? '+' : ''}¥{value.toFixed(2)} + + ), + }, + { + title: '变动后余额', + dataIndex: 'balanceAfter', + render: (value: number) => `¥${value.toFixed(2)}`, + }, + { + title: '关联账单', + dataIndex: 'billId', + render: (value: number) => (value ? `#${value}` : '-'), + }, + { title: '说明', dataIndex: 'description' }, + ]} + /> + )} ); diff --git a/scripts/a2ui-contract.md b/scripts/a2ui-contract.md index cce82ba7..ef3b335d 100644 --- a/scripts/a2ui-contract.md +++ b/scripts/a2ui-contract.md @@ -139,3 +139,23 @@ interface AiUiForm { ## 完成后报告 改动文件清单、跑过的测试命令与结果、与契约的偏差说明。 + +## 实现现状补充(2026-08 修订) + +> 前端 `apps/admin/src/components/AiChat/**` 的实际实现已演进为「统一 artifact」协议, +> 本段描述现行事实,供后续开发对齐;上文的 `ui.form`/`AiUiForm`/`uiForms` 为早期单轨方案, +> 当前仅保留历史兼容读取,新写一律走 artifact。 + +- **统一类型**:`types.ts` 以 `AiArtifactSchema`(`message.uiArtifacts`)为唯一事实源, + `artifact.type` ∈ `form | review | chart | import_wizard`,`payload` 携带各类型 schema。 +- **合并逻辑**:`uiArtifacts.ts` 的 `mergeArtifactIntoMessage` 将新 artifact upsert 进 + `uiArtifacts`,并**向后兼容**地派发到 legacy 字段(`forms`/`reviews`/`charts`)供老消息渲染。 +- **渲染层**:`AiMessageContent.tsx` 消费 legacy 列表渲染 `DynamicForm`/`DynamicReview`/ + `DynamicChart`;每个制品用 `ArtifactErrorBoundary` 包裹(单个渲染失败不影响整条气泡)。 +- **A2UI 组件实现**:`DynamicForm`/`DynamicReview`/`DynamicChart` 共享 + `useSubmissionState`(提交状态:防重复提交 + 失败可重试)与 `useXCardSurface` + (XCard commands 增量更新 + createSurface 自动去重)。 +- **SSE 事件 / 提交与确认接口**:与上文契约一致,未变更。 + +后续若彻底移除 legacy 字段,需保证历史消息(持久化 metadata 中的 `forms`/`reviews`/`charts`) +仍可渲染——建议先迁移历史数据或保留兼容读取。 From 00e2bc5acf77631570abccc5e3df30069a63b4c7 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 17:33:58 +0800 Subject: [PATCH 02/43] =?UTF-8?q?fix(admin):=20=E9=80=9A=E7=9F=A5=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E3=80=81=E7=99=BB=E5=BD=95=E8=BF=87=E6=9C=9F=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E3=80=81=E9=99=84=E4=BB=B6=E6=89=93=E5=BC=80=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E4=B8=8E=E5=AF=BC=E5=87=BA=E7=BB=9F=E4=B8=80=20loadin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通知中心改为游标分页 + 加载更多,历史通知不再被 50 条上限截断; 加载失败显示错误态与重试 - AI 流式请求 401 被登出时,登录页提示"登录已过期",不再无声踢出 - AI 附件/引用来源打开失败时给出明确错误提示,不再"点了没反应" - 新增 useDownload hook:统一导出/下载的防重复、loading 与成功/失败反馈; 接入学生/账单/房间/入住/费用五个页面的模板下载与导出按钮 - 学生页移除不检查响应状态的私有下载实现,统一走 downloadBlob --- .../components/AiChat/AiMessageContent.tsx | 23 +- apps/admin/src/components/AiChat/provider.ts | 2 + apps/admin/src/hooks/useDownload.ts | 46 ++++ apps/admin/src/pages/Bills/index.tsx | 13 +- .../src/pages/Expenses/ExpenseTablePanel.tsx | 6 + apps/admin/src/pages/Expenses/index.tsx | 32 ++- apps/admin/src/pages/Login/index.tsx | 10 +- apps/admin/src/pages/Notifications/index.tsx | 225 ++++++++++-------- .../pages/Occupancies/OccupanciesToolbar.tsx | 12 +- apps/admin/src/pages/Occupancies/index.tsx | 26 +- apps/admin/src/pages/Rooms/RoomsToolbar.tsx | 12 +- apps/admin/src/pages/Rooms/index.tsx | 19 +- .../src/pages/Students/StudentsToolbar.tsx | 6 + apps/admin/src/pages/Students/index.tsx | 41 ++-- 14 files changed, 320 insertions(+), 153 deletions(-) create mode 100644 apps/admin/src/hooks/useDownload.ts diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index 1c221a72..eb85fd6a 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -13,6 +13,7 @@ import type { ThoughtChainItemType } from '@ant-design/x'; import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown'; import { Alert, Button, Flex, Input, Space, Typography } from 'antd'; import { useUserStore } from '../../store/user/userStore'; +import { message } from '../../ui/app-message'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; @@ -104,6 +105,24 @@ async function openSourceUrl(item: { url?: string }): Promise { window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); } +/** 打开附件,失败时给出明确提示(避免「点了没反应」) */ +async function handleOpenAttachment(attachment: AiAttachment): Promise { + try { + await openAttachment(attachment); + } catch (error: unknown) { + message.error(error instanceof Error ? error.message : '附件打开失败,请重试'); + } +} + +/** 打出来源链接,失败时给出明确提示 */ +async function handleOpenSource(item: { url?: string }): Promise { + try { + await openSourceUrl(item); + } catch (error: unknown) { + message.error(error instanceof Error ? error.message : '来源打开失败,请重试'); + } +} + function ToolChain({ tools }: { tools: AiToolRun[] }) { const items = useMemo( () => @@ -228,7 +247,7 @@ export const AiMessageContent: React.FC = ({ byte={attachment.size} size="small" icon={attachmentIcon(attachment)} - onClick={() => void openAttachment(attachment)} + onClick={() => void handleOpenAttachment(attachment)} /> )); @@ -352,7 +371,7 @@ export const AiMessageContent: React.FC = ({ void openSourceUrl(item as { url?: string })} + onClick={(item) => void handleOpenSource(item as { url?: string })} /> )} {(message.forms ?? []).map((form) => ( diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index 69b22f6d..f37e3b53 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -80,6 +80,8 @@ export async function authenticatedFetch( } const response = await fetch(requestInput, { ...requestInit, headers }); if (response.status === 401) { + // 提示由登录页读取展示:直接弹 toast 会被跳转销毁 + sessionStorage.setItem('login_expired_hint', '1'); useUserStore.getState().logout(); usePermissionStore.getState().clearPermissions(); window.location.href = '/login'; diff --git a/apps/admin/src/hooks/useDownload.ts b/apps/admin/src/hooks/useDownload.ts new file mode 100644 index 00000000..5a3842ae --- /dev/null +++ b/apps/admin/src/hooks/useDownload.ts @@ -0,0 +1,46 @@ +import { useCallback, useRef, useState } from 'react'; +import { downloadBlob } from '../utils/download'; +import { message } from '../ui/app-message'; + +export interface DownloadOptions { + /** 成功提示文案;默认「下载成功」 */ + successMsg?: string; + /** 失败提示文案;默认使用接口返回的错误信息 */ + errorMsg?: string; +} + +/** + * 统一下载/导出状态:防重复点击 + 成功/失败反馈。 + * + * 用法: + * const { downloading, run } = useDownload(); + * + + )} + + )} ); diff --git a/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx b/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx index 16517662..30e01c84 100644 --- a/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx +++ b/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx @@ -27,6 +27,8 @@ export const OccupanciesToolbar: React.FC<{ onDepositAmountChange: (value: number) => void; onDownloadTemplate: () => void; onExport: () => void; + templateLoading?: boolean; + exportLoading?: boolean; }> = ({ viewMode, onChangeViewMode, @@ -42,6 +44,8 @@ export const OccupanciesToolbar: React.FC<{ onDepositAmountChange, onDownloadTemplate, onExport, + templateLoading, + exportLoading, }) => { return (
@@ -130,13 +134,19 @@ export const OccupanciesToolbar: React.FC<{ } + loading={templateLoading} onClick={onDownloadTemplate} > 下载模板 ) : null} {viewMode !== 'archived' ? ( - } onClick={onExport}> + } + loading={exportLoading} + onClick={onExport} + > 导出记录 ) : null} diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 76c65b1a..ad9555cf 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -25,6 +25,7 @@ import { useOccupancyMutations } from './useOccupancyMutations'; import { QueryErrorState } from '../../components/QueryState'; import { NextStepHint } from '../../components/NextStepHint'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; +import { useDownload } from '../../hooks/useDownload'; interface StudentLookupRow { id: number; @@ -452,6 +453,9 @@ const OccupanciesPage: React.FC = () => { [selectedRowKeys, viewMode], ); + const { downloading: templateDownloading, run: runTemplateDownload } = useDownload(); + const { downloading: exportDownloading, run: runExportDownload } = useDownload(); + return (
{ depositAmount={depositAmount} onDepositAmountChange={setDepositAmount} onDownloadTemplate={() => { - void import('../../utils/download').then(({ downloadBlob }) => - downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() => message.error('下载失败')), - ); - }} - onExport={() => { - void import('../../utils/download').then(({ downloadBlob }) => { - const params = viewMode === 'active' ? '?active=true' : ''; - const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx'; - downloadBlob('/occupancies/export' + params, filename).catch(() => message.error('导出失败')); + void runTemplateDownload('/occupancies/template', '入住名单导入模板.xlsx', { + successMsg: '模板已下载', + errorMsg: '下载失败', }); }} + templateLoading={templateDownloading} + onExport={() => { + const params = viewMode === 'active' ? '?active=true' : ''; + const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx'; + void runExportDownload('/occupancies/export' + params, filename, { + successMsg: '入住记录已导出', + errorMsg: '导出失败', + }); + }} + exportLoading={exportDownloading} /> {nextStepHint === 'billing' && ( ) => void; onDownloadTemplate: () => void; onExport: () => void; + templateLoading?: boolean; + exportLoading?: boolean; } export const RoomsToolbar: React.FC = ({ @@ -63,6 +65,8 @@ export const RoomsToolbar: React.FC = ({ onImport, onDownloadTemplate, onExport, + templateLoading, + exportLoading, }) => { return (
@@ -185,11 +189,17 @@ export const RoomsToolbar: React.FC = ({ } + loading={templateLoading} onClick={onDownloadTemplate} > 下载模板 - } onClick={onExport}> + } + loading={exportLoading} + onClick={onExport} + > 导出列表 diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index 1aefcd8f..3f8b47c3 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -9,10 +9,10 @@ import { } from 'antd'; import type { UploadRequestOption } from '@rc-component/upload/lib/interface'; import api from '../../api'; -import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; +import { useDownload } from '../../hooks/useDownload'; import { selectArchiveRecords } from '../archive-view'; import { getErrorMessage } from '../../utils/error'; import { QueryErrorState } from '../../components/QueryState'; @@ -391,15 +391,22 @@ const RoomsPage: React.FC = () => { } }; + const { downloading: templateDownloading, run: runTemplateDownload } = useDownload(); + const { downloading: exportDownloading, run: runExportDownload } = useDownload(); + const handleDownloadTemplate = () => { - downloadBlob('/rooms/template', '房间导入模板.xlsx').catch(() => message.error('下载失败')); + void runTemplateDownload('/rooms/template', '房间导入模板.xlsx', { + successMsg: '模板已下载', + errorMsg: '下载失败', + }); }; const handleExport = () => { const params = showArchived ? '?includeArchived=true' : ''; - downloadBlob('/rooms/export' + params, '房间列表.xlsx').catch(() => - message.error('导出失败'), - ); + void runExportDownload('/rooms/export' + params, '房间列表.xlsx', { + successMsg: '房间列表已导出', + errorMsg: '导出失败', + }); }; const columns = useRoomColumns({ @@ -470,7 +477,9 @@ const RoomsPage: React.FC = () => { } }} onDownloadTemplate={handleDownloadTemplate} + templateLoading={templateDownloading} onExport={handleExport} + exportLoading={exportDownloading} /> {isError ? ( diff --git a/apps/admin/src/pages/Students/StudentsToolbar.tsx b/apps/admin/src/pages/Students/StudentsToolbar.tsx index c21053ba..3f2af74c 100644 --- a/apps/admin/src/pages/Students/StudentsToolbar.tsx +++ b/apps/admin/src/pages/Students/StudentsToolbar.tsx @@ -58,6 +58,8 @@ export interface StudentsToolbarProps { onUpdateImport: UploadProps['customRequest']; onDownloadTemplate: () => void; onExport: () => void; + templateLoading?: boolean; + exportLoading?: boolean; } export const StudentsToolbar: React.FC = ({ @@ -94,6 +96,8 @@ export const StudentsToolbar: React.FC = ({ onUpdateImport, onDownloadTemplate, onExport, + templateLoading, + exportLoading, }) => { const { modal } = App.useApp(); return ( @@ -273,6 +277,7 @@ export const StudentsToolbar: React.FC = ({ } + loading={templateLoading} onClick={onDownloadTemplate} > 下载模板 @@ -280,6 +285,7 @@ export const StudentsToolbar: React.FC = ({ } + loading={exportLoading} onClick={onExport} > 导出名单 diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 6bcb9d6f..cdf59b6c 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -6,8 +6,8 @@ import { } from 'antd'; import api from '../../api'; import { usePermission } from '../../hooks/usePermission'; -import { useUserStore } from '../../store/user/userStore'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; +import { useDownload } from '../../hooks/useDownload'; import { QueryErrorState } from '../../components/QueryState'; import { NextStepHint } from '../../components/NextStepHint'; import { selectArchiveRecords } from '../archive-view'; @@ -312,26 +312,14 @@ const StudentsPage: React.FC = () => { [saveCellMutation], ); - const downloadApiFile = async (path: string, filename: string, errorMessage = '下载失败') => { - const baseURL = import.meta.env.PROD - ? '/api' - : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; - const token = useUserStore.getState().token; - try { - const res = await fetch(`${baseURL}${path}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - } catch (error: unknown) { - console.error(errorMessage, error); - message.error(errorMessage); - } + const { downloading: templateDownloading, run: runTemplateDownload } = useDownload(); + const { downloading: exportDownloading, run: runExportDownload } = useDownload(); + + const handleDownloadTemplate = () => { + void runTemplateDownload('/students/template', '学生导入模板.xlsx', { + successMsg: '模板已下载', + errorMsg: '模板下载失败', + }); }; const handleArchive = useCallback( @@ -423,10 +411,6 @@ const StudentsPage: React.FC = () => { } }; - const handleDownloadTemplate = () => { - void downloadApiFile('/students/template', '学生导入模板.xlsx'); - }; - const handleCreateStudentsImport = async ({ file, onSuccess, onError }: any) => { const formData = new FormData(); formData.append('file', file as File); @@ -490,7 +474,10 @@ const StudentsPage: React.FC = () => { if (filterClassId) params.set('classId', String(filterClassId)); if (filterTeacherId) params.set('teacherId', String(filterTeacherId)); const query = params.toString() ? `?${params.toString()}` : ''; - void downloadApiFile(`/students/export${query}`, '学生名单.xlsx', '导出失败'); + void runExportDownload(`/students/export${query}`, '学生名单.xlsx', { + successMsg: '名单已导出', + errorMsg: '导出失败', + }); }; const columns = useMemo( @@ -580,7 +567,9 @@ const StudentsPage: React.FC = () => { onCreateImport={handleCreateStudentsImport} onUpdateImport={handleUpdateExistingStudentsImport} onDownloadTemplate={handleDownloadTemplate} + templateLoading={templateDownloading} onExport={handleExport} + exportLoading={exportDownloading} /> {nextStepHint === 'class' && ( Date: Fri, 7 Aug 2026 17:38:21 +0800 Subject: [PATCH 03/43] =?UTF-8?q?fix(admin):=20=E7=BC=96=E8=BE=91=E6=B6=88?= =?UTF-8?q?=E6=81=AF/=E5=88=87=E6=8D=A2=E4=BC=9A=E8=AF=9D=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E3=80=81=E8=A1=8C=E5=86=85=E7=BC=96=E8=BE=91=E6=92=A4?= =?UTF-8?q?=E9=94=80=E4=B8=8E=E4=B8=8A=E4=BC=A0=E8=BF=9B=E5=BA=A6=E5=8F=8D?= =?UTF-8?q?=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 编辑旧消息会删除其后全部消息时,先弹确认告知影响范围 - 有待发送附件时切换会话,先确认再丢弃(避免静默删除已上传文件) - EditableCell 保存成功后提供 6 秒内"撤销"入口(把旧值重新保存), 重新编辑时自动清除 - 导入向导/重新上传显示上传进度条与百分比,不再"假死" - AI 附件上传透传进度给附件列表 - 租赁合同上传显示按钮 loading 与上传百分比 --- apps/admin/src/api/imports.ts | 9 +++- .../src/components/AiChat/AiChatDrawer.tsx | 29 ++++++++-- apps/admin/src/components/AiChat/api.ts | 9 +++- .../AiChat/useAiChatMessageActions.tsx | 54 ++++++++++++------- .../src/components/EditableCell/index.tsx | 52 +++++++++++++++++- .../src/components/EditableCell/style.css | 23 ++++++++ .../ImportWizard/ImportWizardModal.tsx | 26 ++++++++- .../pages/ClassroomRentals/RentalTable.tsx | 28 ++++++++-- .../src/pages/ClassroomRentals/index.tsx | 25 +++++++-- 9 files changed, 220 insertions(+), 35 deletions(-) diff --git a/apps/admin/src/api/imports.ts b/apps/admin/src/api/imports.ts index 635e8c2a..e6912374 100644 --- a/apps/admin/src/api/imports.ts +++ b/apps/admin/src/api/imports.ts @@ -21,6 +21,8 @@ export async function createImportRun( conversationId?: number; stages?: ImportStageRequest[]; mapping?: Record>; + /** 上传进度回调(0-100) */ + onProgress?: (percent: number) => void; }, ): Promise { const form = new FormData(); @@ -31,7 +33,12 @@ export async function createImportRun( if (options.mapping && Object.keys(options.mapping).length > 0) { form.append('mapping', JSON.stringify(options.mapping)); } - const res = await api.post>('/imports/runs', form); + const res = await api.post>('/imports/runs', form, { + onUploadProgress: (event) => { + if (!options.onProgress || !event.total) return; + options.onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100)); + }, + }); return res.data; } diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.tsx index 86c3d997..1ba62d90 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.tsx @@ -189,11 +189,32 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting const switchConversation = useCallback( (key: string) => { - discardPendingAttachments(); - if (isMobile) setSidebarOpen(false); - setActiveConversationKey(key); + const doSwitch = () => { + discardPendingAttachments(); + if (isMobile) setSidebarOpen(false); + setActiveConversationKey(key); + }; + // 有待发送的附件时先确认,避免静默删除已上传文件 + if (uploadItems.length > 0) { + modal.confirm({ + title: '切换会话将丢弃未发送的附件', + content: `当前有 ${uploadItems.length} 个已上传但未发送的附件,切换会话后将被删除,此操作不可恢复。`, + okText: '切换并丢弃', + okButtonProps: { danger: true }, + cancelText: '留在当前会话', + onOk: doSwitch, + }); + return; + } + doSwitch(); }, - [discardPendingAttachments, isMobile, setActiveConversationKey], + [ + discardPendingAttachments, + isMobile, + modal, + setActiveConversationKey, + uploadItems.length, + ], ); useEffect( diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts index bcbd8c3a..28e5fc44 100644 --- a/apps/admin/src/components/AiChat/api.ts +++ b/apps/admin/src/components/AiChat/api.ts @@ -30,12 +30,19 @@ export const aiChatApi = { `${basePath}/${conversationId}/messages/${messageId}`, ) ).data, - uploadAttachment: async (file: File): Promise => { + uploadAttachment: async ( + file: File, + onProgress?: (percent: number) => void, + ): Promise => { const form = new FormData(); form.append('file', file); return ( await api.post>('/ai/chat/attachments', form, { timeout: 120_000, + onUploadProgress: (event) => { + if (!onProgress || !event.total) return; + onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100)); + }, }) ).data; }, diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index 043032c2..65f03bcc 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -296,30 +296,46 @@ export function useAiChatMessageActions({ setEditingMessageId(null); if (content === messageInfo.message.content) return; - setMessage(messageInfo.id, (info) => ({ - message: { - ...info.message, - content, - metadata: { ...info.message.metadata, edited: true }, - }, - })); + // 编辑旧消息会删除其后的全部消息并重新生成,需先告知用户 const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id); - if (index >= 0) { - for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id); + const followingCount = index >= 0 ? messagesRef.current.length - index - 1 : 0; + const doEdit = () => { + setMessage(messageInfo.id, (info) => ({ + message: { + ...info.message, + content, + metadata: { ...info.message.metadata, edited: true }, + }, + })); + if (index >= 0) { + for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id); + } + requestWithStatus({ + message: content, + attachmentIds: [], + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + editMessageId: messageId, + }); + }; + if (followingCount > 0) { + modal.confirm({ + title: '编辑消息将删除后续内容', + content: `编辑这条消息会删除其后的 ${followingCount} 条消息并重新生成回答,此操作不可恢复。`, + okText: '继续编辑', + cancelText: '取消', + onOk: doEdit, + }); + return; } - requestWithStatus({ - message: content, - attachmentIds: [], - skillKey: activeConversation?.lockedSkillKey ?? null, - clientRequestId: crypto.randomUUID(), - reasoningEffort: deepThinking ? 'high' : null, - editMessageId: messageId, - }); + doEdit(); }, [ activeConversation?.lockedSkillKey, activeId, deepThinking, + modal, removeMessage, requestWithStatus, setMessage, @@ -426,7 +442,9 @@ export function useAiChatMessageActions({ return; } try { - const uploaded = await aiChatApi.uploadAttachment(file); + const uploaded = await aiChatApi.uploadAttachment(file, (percent) => { + options.onProgress?.({ percent }); + }); setAttachments((items) => [...items, uploaded]); options.onSuccess?.(uploaded, file); } catch (error) { diff --git a/apps/admin/src/components/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx index a37a31bc..8e633841 100644 --- a/apps/admin/src/components/EditableCell/index.tsx +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -102,8 +102,18 @@ const EditableCell = ({ const [draft, setDraft] = useState(() => normalizeEditableValue(formatValue ? formatValue(value) : value, editor), ); + // 保存成功后短暂显示「撤销」入口:记录保存前的序列化旧值 + const [undoMeta, setUndoMeta] = useState<{ serializedPrevious: unknown } | null>(null); + const undoTimerRef = useRef(undefined); const enabled = !disabled && (!permission || hasPermission(permission)); + useEffect( + () => () => { + window.clearTimeout(undoTimerRef.current); + }, + [], + ); + const original = useMemo( () => serializeEditableValue( @@ -133,10 +143,15 @@ const EditableCell = ({ return true; } setSaving(true); + const previousValue = original; try { await onSave(parseValue ? parseValue(serialized) : (serialized as Value)); if (activeCell?.id === idRef.current) activeCell = null; setEditing(false); + // 提供 6 秒内的撤销入口(把旧值再保存一次) + setUndoMeta({ serializedPrevious: previousValue }); + window.clearTimeout(undoTimerRef.current); + undoTimerRef.current = window.setTimeout(() => setUndoMeta(null), 6_000); return true; } catch (error) { message.error(getErrorMessage(error, '保存失败')); @@ -197,10 +212,29 @@ const EditableCell = ({ if (!saved) return; } activeCell = { id: idRef.current, save }; + // 重新进入编辑时清掉上一次的撤销入口 + window.clearTimeout(undoTimerRef.current); + setUndoMeta(null); setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); setEditing(true); }; + const handleUndo = async () => { + if (!undoMeta) return; + window.clearTimeout(undoTimerRef.current); + setUndoMeta(null); + try { + await onSave( + parseValue + ? parseValue(undoMeta.serializedPrevious) + : (undoMeta.serializedPrevious as Value), + ); + message.success('已撤销修改'); + } catch (error) { + message.error(getErrorMessage(error, '撤销失败')); + } + }; + const onPointerDown = (event: React.PointerEvent) => { if (event.pointerType !== 'touch' || editing) return; touchStartRef.current = { @@ -308,7 +342,23 @@ const EditableCell = ({ {editing ? ( {control} ) : ( - {children} + + + {children} + {undoMeta ? ( + + ) : null} + + )}
); diff --git a/apps/admin/src/components/EditableCell/style.css b/apps/admin/src/components/EditableCell/style.css index 9e558230..2d7aedae 100644 --- a/apps/admin/src/components/EditableCell/style.css +++ b/apps/admin/src/components/EditableCell/style.css @@ -5,6 +5,29 @@ align-items: center; } +.editable-cell-display { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + width: 100%; +} + +.editable-cell-undo { + flex: none; + border: none; + background: transparent; + color: #1677ff; + font-size: 12px; + padding: 0; + cursor: pointer; + white-space: nowrap; +} + +.editable-cell-undo:hover { + text-decoration: underline; +} + .editable-cell--enabled { cursor: cell; touch-action: manipulation; diff --git a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx index 223c8aca..66d89577 100644 --- a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx +++ b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx @@ -14,6 +14,7 @@ import { Descriptions, Flex, Modal, + Progress, Select, Space, Spin, @@ -115,6 +116,7 @@ export const ImportWizardModal: React.FC = ({ const [run, setRun] = useState(null); const [loadingRun, setLoadingRun] = useState(false); const [uploading, setUploading] = useState(false); + const [uploadPercent, setUploadPercent] = useState(0); const [activeStepKey, setActiveStepKey] = useState(null); const [sheetSelection, setSheetSelection] = useState>({}); const [mappingDraft, setMappingDraft] = useState>>({}); @@ -188,14 +190,19 @@ export const ImportWizardModal: React.FC = ({ const handleUpload: UploadProps['customRequest'] = async (options) => { const file = options.file as File; setUploading(true); + setUploadPercent(0); try { - const detail = await createImportRun(file, { source: 'manual' }); + const detail = await createImportRun(file, { + source: 'manual', + onProgress: (percent) => setUploadPercent(percent), + }); await loadRun(detail.id); message.success(`已识别 ${detail.sheets.length} 个工作表`); } catch (error) { message.error(error instanceof Error ? error.message : '文件上传失败'); } finally { setUploading(false); + setUploadPercent(0); } }; @@ -256,11 +263,13 @@ export const ImportWizardModal: React.FC = ({ const handleReupload = async (file: File) => { if (!run || !activeStepKey) return; setUploading(true); + setUploadPercent(0); try { const detail = await createImportRun(file, { source: 'manual', stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }], mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} }, + onProgress: (percent) => setUploadPercent(percent), }); await loadRun(detail.id); message.success('已重新上传,并保留原列映射'); @@ -268,6 +277,7 @@ export const ImportWizardModal: React.FC = ({ message.error(error instanceof Error ? error.message : '重新上传失败'); } finally { setUploading(false); + setUploadPercent(0); } }; @@ -405,6 +415,20 @@ export const ImportWizardModal: React.FC = ({

点击或拖拽 .xlsx / .csv 文件到此区域

单文件不超过 10MB;.xls 请先另存为 .xlsx

+ {uploading ? ( + + 0 && uploadPercent < 100 ? 'active' : 'normal'} + /> + + {uploadPercent > 0 && uploadPercent < 100 + ? `正在上传 ${uploadPercent}%...` + : '正在上传并解析文件...'} + + + ) : null} ) : ( diff --git a/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx index 9a58f714..c3d7dcde 100644 --- a/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx +++ b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Button, Empty, @@ -43,7 +43,11 @@ export interface RentalTableProps { onPurge: (id: number, name: string) => void; onDownloadContract: (id: number, filename?: string) => void; onDeleteContract: (id: number) => void; - onUploadContract: (id: number, formData: FormData) => Promise; + onUploadContract: ( + id: number, + formData: FormData, + onProgress?: (percent: number) => void, + ) => Promise; } export const RentalTable: React.FC = ({ @@ -62,6 +66,9 @@ export const RentalTable: React.FC = ({ onDeleteContract, onUploadContract, }) => { + const [uploadingContractId, setUploadingContractId] = useState(null); + const [contractPercent, setContractPercent] = useState(0); + const EditableRentalCell = ({ value, field, @@ -250,17 +257,28 @@ export const RentalTable: React.FC = ({ } const formData = new FormData(); formData.append('file', file); + setUploadingContractId(r.id); + setContractPercent(0); try { - await onUploadContract(r.id, formData); + await onUploadContract(r.id, formData, (percent) => setContractPercent(percent)); message.success('合同已上传'); onSuccess?.({}); } catch (e) { onError?.(e as Error); + } finally { + setUploadingContractId(null); + setContractPercent(0); } }} > - ) : ( diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index d59bd241..ba9bf1f0 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -140,8 +140,21 @@ const ClassroomRentalsPage: React.FC = () => { { invalidate: [['classroom-rentals']] }, ); const uploadContractMutation = useApiMutation( - async ({ id, formData }: { id: number; formData: FormData }) => - api.post(`/classroom-rentals/${id}/contract`, formData), + async ({ + id, + formData, + onProgress, + }: { + id: number; + formData: FormData; + onProgress?: (percent: number) => void; + }) => + api.post(`/classroom-rentals/${id}/contract`, formData, { + onUploadProgress: (event) => { + if (!onProgress || !event.total) return; + onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100)); + }, + }), { invalidate: [['classroom-rentals']] }, ); @@ -319,8 +332,12 @@ const ClassroomRentalsPage: React.FC = () => { } }; - const handleUploadContract = async (id: number, formData: FormData) => { - return uploadContractMutation.mutateAsync({ id, formData }); + const handleUploadContract = async ( + id: number, + formData: FormData, + onProgress?: (percent: number) => void, + ) => { + return uploadContractMutation.mutateAsync({ id, formData, onProgress }); }; const openEdit = (record: any) => { From ff3b6cdfdeb2040ab2b16fc2ea9c743aebdb713d Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 17:41:01 +0800 Subject: [PATCH 04/43] =?UTF-8?q?style(admin):=20=E6=B8=85=E7=90=86=20AI?= =?UTF-8?q?=20slop=20=E7=97=95=E8=BF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除教师端 hero 的英文眉标(TEACHING DAY),日期并入正文描述 - 删除不再使用的 attendance-eyebrow 样式 - Dashboard 的 ══ 装饰注释改为普通注释 kill-ai-slop 扫描: 18 处命中 → 7 处(剩余均为合理用法: 链接 hover 下划线、头像圆形角) --- apps/admin/src/pages/Attendance/attendance.css | 7 ------- apps/admin/src/pages/Attendance/teacher.tsx | 7 +++---- apps/admin/src/pages/Dashboard/index.tsx | 18 +++++++++--------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css index e6a2c6cc..85040344 100644 --- a/apps/admin/src/pages/Attendance/attendance.css +++ b/apps/admin/src/pages/Attendance/attendance.css @@ -59,13 +59,6 @@ font-size: 15px; } -.attendance-eyebrow { - font-size: 11px; - font-weight: 700; - letter-spacing: 1.7px; - opacity: 0.72; -} - .teacher-topbar { display: flex; align-items: center; diff --git a/apps/admin/src/pages/Attendance/teacher.tsx b/apps/admin/src/pages/Attendance/teacher.tsx index 16d39c13..237fed7c 100644 --- a/apps/admin/src/pages/Attendance/teacher.tsx +++ b/apps/admin/src/pages/Attendance/teacher.tsx @@ -76,11 +76,10 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c
- - TEACHING DAY · {dayjs().format('MM月DD日 dddd')} -

今天,从课程开始

-

课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。

+

+ {dayjs().format('MM月DD日 dddd')} · 课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。 +

- {/* ═══════════ 待办与异常 ═══════════ */} + {/* 待办与异常 */} { pendingDeposits={pendingDeposits} /> - {/* ═══════════ 核心 KPI ═══════════ */} + {/* 核心 KPI */}
@@ -307,7 +307,7 @@ const DashboardPage: React.FC = () => { - {/* ═══════════ 更多指标(折叠) ═══════════ */} + {/* 更多指标(折叠) */} { ]} /> - {/* ═══════════ 图表:考勤趋势 + 出勤分布 ═══════════ */} + {/* 图表:考勤趋势 + 出勤分布 */} @@ -469,7 +469,7 @@ const DashboardPage: React.FC = () => { - {/* ═══════════ 图表:班级出勤排行 ═══════════ */} + {/* 图表:班级出勤排行 */} @@ -497,7 +497,7 @@ const DashboardPage: React.FC = () => { - {/* ═══════════ 图表:费用分布 + 宿舍排行 ═══════════ */} + {/* 图表:费用分布 + 宿舍排行 */} @@ -525,7 +525,7 @@ const DashboardPage: React.FC = () => { - {/* ═══════════ 图表:月度收入趋势 ═══════════ */} + {/* 图表:月度收入趋势 */} @@ -541,10 +541,10 @@ const DashboardPage: React.FC = () => { - {/* ═══════════ 图表:教室占用热力图(懒加载) ═══════════ */} + {/* 图表:教室占用热力图(懒加载) */} - {/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */} + {/* 图表:入住时间线甘特图(懒加载) */} ); From bcbf3c8b2e48f49943350f250f26a086b88799c4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 17:43:00 +0800 Subject: [PATCH 05/43] =?UTF-8?q?refactor(admin):=20=E6=8C=89=20aislop=20?= =?UTF-8?q?=E8=B4=A8=E9=87=8F=E9=97=A8=E7=A6=81=E4=BF=AE=E5=A4=8D=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E4=BB=A3=E7=A0=81=E8=B4=A8=E9=87=8F=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RoleTour: buildSteps 拆分为各角色的声明式步骤常量,函数从 91 行降至组装逻辑 - ImportWizardModal: 抽取 uploadRun 共享 helper,消除 handleUpload/handleReupload 之间重复的上传进度与 loading 状态处理 aislop scan --changes: 99/100 → 100/100 (5 引擎 0 issues) --- .../ImportWizard/ImportWizardModal.tsx | 48 +++-- .../src/components/Onboarding/RoleTour.tsx | 170 +++++++++--------- 2 files changed, 117 insertions(+), 101 deletions(-) diff --git a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx index 66d89577..eb6b2e32 100644 --- a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx +++ b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx @@ -40,6 +40,7 @@ import { type ImportPreviewResult, type ImportReceipt, type ImportRunDetail, + type ImportStageRequest, type ImportStepKey, } from './types'; @@ -187,25 +188,41 @@ export const ImportWizardModal: React.FC = ({ return [...headers]; }, [run, activeStepKey, sheetSelection]); - const handleUpload: UploadProps['customRequest'] = async (options) => { - const file = options.file as File; + /** 创建导入任务并加载详情:统一处理上传进度与 loading 状态。成功返回 run 详情,失败返回 null */ + const uploadRun = async ( + file: File, + options: { + source: 'ai' | 'manual'; + conversationId?: number; + stages?: ImportStageRequest[]; + mapping?: Record>; + }, + errorMessage: string, + ): Promise => { setUploading(true); setUploadPercent(0); try { const detail = await createImportRun(file, { - source: 'manual', + ...options, onProgress: (percent) => setUploadPercent(percent), }); await loadRun(detail.id); - message.success(`已识别 ${detail.sheets.length} 个工作表`); + return detail; } catch (error) { - message.error(error instanceof Error ? error.message : '文件上传失败'); + message.error(error instanceof Error ? error.message : errorMessage); + return null; } finally { setUploading(false); setUploadPercent(0); } }; + const handleUpload: UploadProps['customRequest'] = async (options) => { + const file = options.file as File; + const detail = await uploadRun(file, { source: 'manual' }, '文件上传失败'); + if (detail) message.success(`已识别 ${detail.sheets.length} 个工作表`); + }; + const handlePreview = async () => { if (!run || !activeStepKey || !activeStep) return; const mapping = mappingDraft[activeStepKey] ?? {}; @@ -262,23 +279,16 @@ export const ImportWizardModal: React.FC = ({ const handleReupload = async (file: File) => { if (!run || !activeStepKey) return; - setUploading(true); - setUploadPercent(0); - try { - const detail = await createImportRun(file, { + const detail = await uploadRun( + file, + { source: 'manual', stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }], mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} }, - onProgress: (percent) => setUploadPercent(percent), - }); - await loadRun(detail.id); - message.success('已重新上传,并保留原列映射'); - } catch (error) { - message.error(error instanceof Error ? error.message : '重新上传失败'); - } finally { - setUploading(false); - setUploadPercent(0); - } + }, + '重新上传失败', + ); + if (detail) message.success('已重新上传,并保留原列映射'); }; const previewRows = useMemo(() => { diff --git a/apps/admin/src/components/Onboarding/RoleTour.tsx b/apps/admin/src/components/Onboarding/RoleTour.tsx index cbbf1971..9a8a394e 100644 --- a/apps/admin/src/components/Onboarding/RoleTour.tsx +++ b/apps/admin/src/components/Onboarding/RoleTour.tsx @@ -25,94 +25,100 @@ function menuTarget(label: string): () => HTMLElement { const aiTarget = (): HTMLElement => document.querySelector('button[aria-label="打开 AI 助理"]') as HTMLElement; -function buildSteps(domains: Set): TourStep[] { - const steps: TourStep[] = []; - const has = (key: string) => domains.has(key); +/** 各角色的引导步骤(声明式,按角色域组装) */ +const TEACHER_STEPS: TourStep[] = [ + { + target: menuTarget('今日教学'), + title: '今日教学', + description: '在这里查看今天的课程安排。课程开始后可以拉取钉钉考勤并点名。', + }, + { + target: menuTarget('课程考勤'), + title: '课程考勤', + description: '查看历史考勤记录,课程截止后系统会自动结算缺勤。', + }, +]; - if (has('teacher')) { - steps.push( - { - target: menuTarget('今日教学'), - title: '今日教学', - description: '在这里查看今天的课程安排。课程开始后可以拉取钉钉考勤并点名。', - }, - { - target: menuTarget('课程考勤'), - title: '课程考勤', - description: '查看历史考勤记录,课程截止后系统会自动结算缺勤。', - }, - ); - } - if (has('academic')) { - steps.push( - { - target: menuTarget('学生管理'), - title: '学生管理', - description: '管理学生档案:可单个录入、Excel 批量导入,或用 AI 助手帮你录入。', - }, - { - target: menuTarget('班级管理'), - title: '班级管理', - description: '学生入学后先分班,再排课、考勤,形成完整教学闭环。', - }, - ); - } - if (has('accommodation')) { - steps.push( - { - target: menuTarget('住宿总览'), - title: '住宿总览', - description: '可视化查看各宿舍入住情况,支持历史日期回溯和查寝。', - }, - { - target: menuTarget('入住管理'), - title: '入住管理', - description: '学生入住/退宿/换宿都在这里办理。入住后再录费用、生成账单。', - }, - { - target: menuTarget('账单管理'), - title: '账单管理', - description: '每月生成账单、确认并标记已付,完成「住宿→计费」闭环。', - }, - ); - } - if (has('classroom')) { - steps.push( - { - target: menuTarget('教室排期'), - title: '教室排期', - description: '查看教室占用与排期,避免时间冲突。', - }, - { - target: menuTarget('租赁订单'), - title: '租赁订单', - description: '管理教室租赁订单与合同,跟踪租期与状态。', - }, - ); - } - if (has('system') || has('super')) { - steps.push( - { - target: menuTarget('账号管理'), - title: '账号管理', - description: '管理系统账号、角色与权限,为不同岗位分配对应能力。', - }, - ); - } - if (has('super')) { - steps.unshift({ - target: menuTarget('数据面板'), - title: '数据面板', - description: '全局经营概览:入住率、收入、待办都在这里。', - }); - } +const ACADEMIC_STEPS: TourStep[] = [ + { + target: menuTarget('学生管理'), + title: '学生管理', + description: '管理学生档案:可单个录入、Excel 批量导入,或用 AI 助手帮你录入。', + }, + { + target: menuTarget('班级管理'), + title: '班级管理', + description: '学生入学后先分班,再排课、考勤,形成完整教学闭环。', + }, +]; - steps.push({ +const ACCOMMODATION_STEPS: TourStep[] = [ + { + target: menuTarget('住宿总览'), + title: '住宿总览', + description: '可视化查看各宿舍入住情况,支持历史日期回溯和查寝。', + }, + { + target: menuTarget('入住管理'), + title: '入住管理', + description: '学生入住/退宿/换宿都在这里办理。入住后再录费用、生成账单。', + }, + { + target: menuTarget('账单管理'), + title: '账单管理', + description: '每月生成账单、确认并标记已付,完成「住宿→计费」闭环。', + }, +]; + +const CLASSROOM_STEPS: TourStep[] = [ + { + target: menuTarget('教室排期'), + title: '教室排期', + description: '查看教室占用与排期,避免时间冲突。', + }, + { + target: menuTarget('租赁订单'), + title: '租赁订单', + description: '管理教室租赁订单与合同,跟踪租期与状态。', + }, +]; + +const SYSTEM_STEPS: TourStep[] = [ + { + target: menuTarget('账号管理'), + title: '账号管理', + description: '管理系统账号、角色与权限,为不同岗位分配对应能力。', + }, +]; + +const SUPER_STEPS: TourStep[] = [ + { + target: menuTarget('数据面板'), + title: '数据面板', + description: '全局经营概览:入住率、收入、待办都在这里。', + }, +]; + +const AI_STEPS: TourStep[] = [ + { target: aiTarget, title: 'AI 助手', description: '有任何问题都可以问我。我可以帮你查询数据、录入学生、生成批量导入预览——写操作都会先经你确认。', - }); + }, +]; + +function buildSteps(domains: Set): TourStep[] { + const steps: TourStep[] = []; + const has = (key: string) => domains.has(key); + + if (has('super')) steps.unshift(...SUPER_STEPS); + if (has('teacher')) steps.push(...TEACHER_STEPS); + if (has('academic')) steps.push(...ACADEMIC_STEPS); + if (has('accommodation')) steps.push(...ACCOMMODATION_STEPS); + if (has('classroom')) steps.push(...CLASSROOM_STEPS); + if (has('system') || has('super')) steps.push(...SYSTEM_STEPS); + steps.push(...AI_STEPS); return steps; } From c10476d203ffd7e75b17632b280d359747dfe0f8 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 17:56:13 +0800 Subject: [PATCH 06/43] =?UTF-8?q?feat(admin):=20=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E6=9C=AA=E4=BF=9D=E5=AD=98=E5=86=85=E5=AE=B9=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E4=B8=8E=E7=A9=BA=E7=8A=B6=E6=80=81=E5=BC=95=E5=AF=BC=E6=89=A9?= =?UTF-8?q?=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 useDirtyGuard hook:关闭弹窗时若表单被修改,先确认再关闭 - 接入 17 个弹窗:账号/密码/档案、角色、教室、宿舍/床位/柜子、 考勤机、机构、班级、教师、考试、排课、入住/退宿/换房、费用 (子组件弹窗用 useEffect 在回填后快照,时序正确) - 学生列表空状态带「添加学生」引导动作 - 排课创建成功提示「同步钉钉」,考试创建成功提示「去详情录成绩」 aislop scan: 5 引擎 0 issues --- apps/admin/src/hooks/useDirtyGuard.ts | 51 +++++++++++++++++++ apps/admin/src/pages/AttendanceDevices.tsx | 14 +++-- apps/admin/src/pages/Classes/index.tsx | 8 ++- apps/admin/src/pages/Classrooms/index.tsx | 16 ++++-- apps/admin/src/pages/Exams/index.tsx | 25 ++++++++- .../src/pages/Expenses/ExpenseModals.tsx | 27 ++++++++-- .../src/pages/Occupancies/OccupancyModals.tsx | 35 +++++++++++-- apps/admin/src/pages/Organizations/index.tsx | 5 +- apps/admin/src/pages/Roles/index.tsx | 8 ++- apps/admin/src/pages/Rooms/index.tsx | 40 ++++++++++----- .../src/pages/Schedules/ScheduleModals.tsx | 11 +++- apps/admin/src/pages/Schedules/index.tsx | 14 +++++ .../src/pages/Students/StudentsTable.tsx | 27 +++++++++- apps/admin/src/pages/Students/index.tsx | 12 +++++ apps/admin/src/pages/Teachers/index.tsx | 7 ++- apps/admin/src/pages/Users/index.tsx | 22 +++++--- 16 files changed, 273 insertions(+), 49 deletions(-) create mode 100644 apps/admin/src/hooks/useDirtyGuard.ts diff --git a/apps/admin/src/hooks/useDirtyGuard.ts b/apps/admin/src/hooks/useDirtyGuard.ts new file mode 100644 index 00000000..2222aaeb --- /dev/null +++ b/apps/admin/src/hooks/useDirtyGuard.ts @@ -0,0 +1,51 @@ +import { useCallback, useRef } from 'react'; +import { App } from 'antd'; +import type { FormInstance } from 'antd'; + +/** + * 弹窗「未保存内容」保护:关闭弹窗时若表单值已被修改,先确认再关闭, + * 避免用户误关丢失已填内容。 + * + * 用法: + * const { confirmClose, snapshot } = useDirtyGuard(form); + * // 打开弹窗(或编辑回填后)时调用一次 snapshot() 记录初始值 + * const openEdit = () => { form.setFieldsValue(record); snapshot(); setOpen(true); }; + * // Modal 的 onCancel 改用确认关闭 + * confirmClose(() => setOpen(false))} ...> + */ +export function useDirtyGuard(form: FormInstance) { + const { modal } = App.useApp(); + const pristineRef = useRef(''); + + /** 记录当前表单值为「未修改」基准;打开弹窗/回填后调用 */ + const snapshot = useCallback(() => { + pristineRef.current = JSON.stringify(form.getFieldsValue()); + }, [form]); + + /** 表单是否有未保存修改(与 snapshot 时对比) */ + const isDirty = useCallback(() => { + return JSON.stringify(form.getFieldsValue()) !== pristineRef.current; + }, [form]); + + const confirmClose = useCallback( + (close: () => void) => { + if (!isDirty()) { + close(); + return; + } + modal.confirm({ + title: '放弃未保存的修改?', + content: '当前表单有未保存的内容,关闭后修改将丢失。', + okText: '放弃修改', + okButtonProps: { danger: true }, + cancelText: '继续编辑', + onOk: close, + }); + }, + [isDirty, modal], + ); + + return { confirmClose, snapshot, isDirty }; +} + +export default useDirtyGuard; diff --git a/apps/admin/src/pages/AttendanceDevices.tsx b/apps/admin/src/pages/AttendanceDevices.tsx index 685049ff..169b9f13 100644 --- a/apps/admin/src/pages/AttendanceDevices.tsx +++ b/apps/admin/src/pages/AttendanceDevices.tsx @@ -11,6 +11,7 @@ import PermissionButton from '../components/PermissionButton'; import EditableCell from '../components/EditableCell'; import { QueryErrorState } from '../components/QueryState'; import { message } from '../ui/app-message'; +import { useDirtyGuard } from '../hooks/useDirtyGuard'; interface ClassroomOption { id: number; @@ -40,6 +41,7 @@ const AttendanceDevicesPage: React.FC = () => { const [saving, setSaving] = useState(false); const [keyword, setKeyword] = useState(''); const [form] = Form.useForm(); + const formGuard = useDirtyGuard(form); const { data: fetchResult = { devices: [], classrooms: [] }, @@ -107,6 +109,7 @@ const AttendanceDevicesPage: React.FC = () => { setEditing(null); form.resetFields(); form.setFieldsValue({ status: 'active' }); + formGuard.snapshot(); setModalOpen(true); }; @@ -120,6 +123,7 @@ const AttendanceDevicesPage: React.FC = () => { location: record.location, notes: record.notes, }); + formGuard.snapshot(); setModalOpen(true); }; @@ -325,10 +329,12 @@ const AttendanceDevicesPage: React.FC = () => { title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'} open={modalOpen} onOk={handleSave} - onCancel={() => { - setModalOpen(false); - setEditing(null); - }} + onCancel={() => + formGuard.confirmClose(() => { + setModalOpen(false); + setEditing(null); + }) + } confirmLoading={saving} okText="保存" > diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index 8df4d3e4..05e6c079 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -32,6 +32,7 @@ import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { QueryErrorState } from '../../components/QueryState'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; +import { useDirtyGuard } from '../../hooks/useDirtyGuard'; interface ClassItem { id: number; @@ -88,6 +89,7 @@ const ClassesPage: React.FC = () => { const [filterStatus, setFilterStatus] = useState(); const [filterType, setFilterType] = useState(); const [form] = Form.useForm(); + const classFormGuard = useDirtyGuard(form); const [saving, setSaving] = useState(false); const [showArchived, setShowArchived] = useState(false); @@ -178,6 +180,7 @@ const ClassesPage: React.FC = () => { const handleCreate = () => { setEditing(null); form.resetFields(); + classFormGuard.snapshot(); setModalOpen(true); }; @@ -190,9 +193,10 @@ const ClassesPage: React.FC = () => { startDate: record.startDate ? dayjs(record.startDate) : undefined, endDate: record.endDate ? dayjs(record.endDate) : undefined, }); + classFormGuard.snapshot(); setModalOpen(true); }, - [form], + [form, classFormGuard], ); const handleSubmit = async () => { @@ -457,7 +461,7 @@ const ClassesPage: React.FC = () => { title={editing ? '编辑班级' : '创建班级'} open={modalOpen} onOk={handleSubmit} - onCancel={() => setModalOpen(false)} + onCancel={() => classFormGuard.confirmClose(() => setModalOpen(false))} confirmLoading={saving} width={600} > diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index e2a5bd4a..711a3de4 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -34,6 +34,7 @@ import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { useUserStore } from '../../store/user/userStore'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; +import { useDirtyGuard } from '../../hooks/useDirtyGuard'; const statusMap: Record = { available: { text: '可用', color: 'green' }, @@ -63,6 +64,7 @@ const ClassroomsPage: React.FC = () => { const [editing, setEditing] = useState(null); const [showArchived, setShowArchived] = useState(false); const [form] = Form.useForm(); + const formGuard = useDirtyGuard(form); const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); @@ -383,6 +385,7 @@ const ClassroomsPage: React.FC = () => { onClick={() => { setEditing(record); form.setFieldsValue(record); + formGuard.snapshot(); setModalOpen(true); }} > @@ -408,7 +411,7 @@ const ClassroomsPage: React.FC = () => { ), }, ], - [handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form], + [handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form, formGuard], ); return ( @@ -461,6 +464,7 @@ const ClassroomsPage: React.FC = () => { onClick={() => { setEditing(null); form.resetFields(); + formGuard.snapshot(); setModalOpen(true); }} > @@ -540,10 +544,12 @@ const ClassroomsPage: React.FC = () => { title={editing ? '编辑教室' : '添加教室'} open={modalOpen} onOk={handleSave} - onCancel={() => { - setModalOpen(false); - setEditing(null); - }} + onCancel={() => + formGuard.confirmClose(() => { + setModalOpen(false); + setEditing(null); + }) + } confirmLoading={saving} okText="保存" > diff --git a/apps/admin/src/pages/Exams/index.tsx b/apps/admin/src/pages/Exams/index.tsx index b95f4e4f..d38b5537 100644 --- a/apps/admin/src/pages/Exams/index.tsx +++ b/apps/admin/src/pages/Exams/index.tsx @@ -40,7 +40,9 @@ import { validateResponse } from '../../utils/validate'; import { classOptionsSchema, examsSchema } from '../../api/schemas'; import { getErrorMessage } from '../../utils/error'; import { QueryErrorState } from '../../components/QueryState'; +import { NextStepHint } from '../../components/NextStepHint'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; +import { useDirtyGuard } from '../../hooks/useDirtyGuard'; const ExamsPage: React.FC = () => { const { modal } = App.useApp(); @@ -48,6 +50,7 @@ const ExamsPage: React.FC = () => { const { hasPermission } = usePermission(); const canPurgeExam = hasPermission('exam:purge'); const [form] = Form.useForm(); + const examFormGuard = useDirtyGuard(form); const [batchLoading, setBatchLoading] = useState(false); const [saving, setSaving] = useState(false); const [modalOpen, setModalOpen] = useState(false); @@ -56,6 +59,8 @@ const ExamsPage: React.FC = () => { const [classId, setClassId] = useState(); const [showArchived, setShowArchived] = useState(false); const [selectedExamIds, setSelectedExamIds] = useState([]); + // 考试创建成功后的「下一步:去详情录成绩」引导 + const [examCreatedId, setExamCreatedId] = useState(null); const [debouncedFilters] = useDebounceValue( { keyword, examType, classId, showArchived }, 200, @@ -152,6 +157,7 @@ const ExamsPage: React.FC = () => { const openCreate = () => { form.resetFields(); form.setFieldValue('examDate', dayjs()); + examFormGuard.snapshot(); setModalOpen(true); }; @@ -160,9 +166,10 @@ const ExamsPage: React.FC = () => { const values = await form.validateFields(); setSaving(true); const payload = { ...values, examDate: values.examDate.format('YYYY-MM-DD') }; - await saveMutation.mutateAsync(payload); + const created = (await saveMutation.mutateAsync(payload)) as { id?: number }; message.success('考试已创建'); setModalOpen(false); + if (created?.id != null) setExamCreatedId(created.id); } catch { // 校验错误静默,接口错误由 useApiMutation 统一提示 } finally { @@ -255,6 +262,20 @@ const ExamsPage: React.FC = () => { return (
+ {examCreatedId !== null && ( + { + navigate(`/exams/${examCreatedId}`); + setExamCreatedId(null); + }, + }} + onClose={() => setExamCreatedId(null)} + /> + )}
{ saving={saving} form={form} classes={classes} - onCancel={() => setModalOpen(false)} + onCancel={() => examFormGuard.confirmClose(() => setModalOpen(false))} onSubmit={() => void submit()} />
diff --git a/apps/admin/src/pages/Expenses/ExpenseModals.tsx b/apps/admin/src/pages/Expenses/ExpenseModals.tsx index eb35e824..efdc22e1 100644 --- a/apps/admin/src/pages/Expenses/ExpenseModals.tsx +++ b/apps/admin/src/pages/Expenses/ExpenseModals.tsx @@ -1,5 +1,5 @@ // aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 -import React from 'react'; +import React, { useEffect } from 'react'; import { DatePicker, Form, @@ -8,6 +8,7 @@ import { Modal, Select, } from 'antd'; +import { useDirtyGuard } from '../../hooks/useDirtyGuard'; const { RangePicker } = DatePicker; @@ -21,12 +22,18 @@ export const RoomExpenseModal: React.FC<{ onOk: () => void; onCancel: () => void; }> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => { + const roomExpenseGuard = useDirtyGuard(form); + // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准 + useEffect(() => { + if (open) roomExpenseGuard.snapshot(); + }, [open, roomExpenseGuard]); + return ( roomExpenseGuard.confirmClose(onCancel)} okText={editing ? '保存' : '确认录入'} confirmLoading={saving} > @@ -70,12 +77,18 @@ export const UtilityModal: React.FC<{ onOk: () => void; onCancel: () => void; }> = ({ open, saving, form, students, onOk, onCancel }) => { + const utilityGuard = useDirtyGuard(form); + // 打开弹窗时记录当前表单值为「未修改」基准 + useEffect(() => { + if (open) utilityGuard.snapshot(); + }, [open, utilityGuard]); + return ( utilityGuard.confirmClose(onCancel)} okText="生成账单并扣余额" confirmLoading={saving} > @@ -123,12 +136,18 @@ export const PersonalExpenseModal: React.FC<{ onOk: () => void; onCancel: () => void; }> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => { + const personalExpenseGuard = useDirtyGuard(form); + // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准 + useEffect(() => { + if (open) personalExpenseGuard.snapshot(); + }, [open, personalExpenseGuard]); + return ( personalExpenseGuard.confirmClose(onCancel)} okText={editing ? '保存' : '确认录入'} confirmLoading={saving} > diff --git a/apps/admin/src/pages/Occupancies/OccupancyModals.tsx b/apps/admin/src/pages/Occupancies/OccupancyModals.tsx index ebac5d7e..a4704255 100644 --- a/apps/admin/src/pages/Occupancies/OccupancyModals.tsx +++ b/apps/admin/src/pages/Occupancies/OccupancyModals.tsx @@ -1,5 +1,5 @@ // aislop-ignore-file: duplicate-block -- 退宿/换房表单结构相似且字段不同,已共享 DateFormItem -import React from 'react'; +import React, { useEffect } from 'react'; import { DatePicker, Form, @@ -12,6 +12,7 @@ import { } from 'antd'; import type { Dayjs } from 'dayjs'; import { maskIdNumber, maskPhone } from '../../utils/sensitive'; +import { useDirtyGuard } from '../../hooks/useDirtyGuard'; import type { OccupancyRow } from './OccupancyColumns'; export type FormRule = React.ComponentProps['rules']; @@ -76,12 +77,18 @@ export const CheckInModal: React.FC<{ onOk, onCancel, }) => { + const checkInGuard = useDirtyGuard(form); + // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准 + useEffect(() => { + if (open && canCheckIn) checkInGuard.snapshot(); + }, [open, canCheckIn, checkInGuard]); + return ( checkInGuard.confirmClose(onCancel)} okText="确认入住" confirmLoading={saving} width={500} @@ -234,12 +241,18 @@ export const CheckOutModal: React.FC<{ onOk: () => void; onCancel: () => void; }> = ({ record, canCheckOut, saving, form, dateNotBefore, onOk, onCancel }) => { + const checkOutGuard = useDirtyGuard(form); + // 父组件打开退宿弹窗前会用 record 回填表单,这里记录「未修改」基准 + useEffect(() => { + if (record && canCheckOut) checkOutGuard.snapshot(); + }, [record, canCheckOut, checkOutGuard]); + return ( checkOutGuard.confirmClose(onCancel)} okText="确认退宿" confirmLoading={saving} > @@ -308,12 +321,18 @@ export const BatchCheckOutModal: React.FC<{ onOk, onCancel, }) => { + const batchCheckOutGuard = useDirtyGuard(form); + // 父组件在打开批量退宿弹窗前已完成表单回填,这里记录「未修改」基准 + useEffect(() => { + if (open && canCheckOut) batchCheckOutGuard.snapshot(); + }, [open, canCheckOut, batchCheckOutGuard]); + return ( batchCheckOutGuard.confirmClose(onCancel)} okText="确认批量退宿" width={500} > @@ -414,12 +433,18 @@ export const TransferModal: React.FC<{ onOk, onCancel, }) => { + const transferGuard = useDirtyGuard(form); + // 父组件打开换房弹窗前会用 record 回填表单,这里记录「未修改」基准 + useEffect(() => { + if (record && canTransfer) transferGuard.snapshot(); + }, [record, canTransfer, transferGuard]); + return ( transferGuard.confirmClose(onCancel)} okText="确认换房" confirmLoading={saving} width={500} diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index 3b5f40b8..ca39730e 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -11,6 +11,7 @@ import { usePermission } from '../../hooks/usePermission'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { organizationsSchema } from '../../api/schemas'; +import { useDirtyGuard } from '../../hooks/useDirtyGuard'; const PRESET_COLORS = [ '#ff7875', @@ -52,6 +53,7 @@ const OrganizationsPage: React.FC = () => { const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); + const orgGuard = useDirtyGuard(form); const [saving, setSaving] = useState(false); const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(); @@ -133,6 +135,7 @@ const OrganizationsPage: React.FC = () => { form.resetFields(); if (record) form.setFieldsValue(record); else form.setFieldsValue({ color: PRESET_COLORS[data.length % PRESET_COLORS.length] }); + orgGuard.snapshot(); setModalOpen(true); }; @@ -408,7 +411,7 @@ const OrganizationsPage: React.FC = () => { title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'} open={modalOpen} onOk={handleSave} - onCancel={() => setModalOpen(false)} + onCancel={() => orgGuard.confirmClose(() => setModalOpen(false))} confirmLoading={saving} okText="保存" > diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index 649b4bfc..2e113785 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -10,6 +10,7 @@ import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { permissionTreeSchema, rolesSchema } from '../../api/schemas'; import { getErrorMessage } from '../../utils/error'; +import { useDirtyGuard } from '../../hooks/useDirtyGuard'; interface PermissionItem { id: number; @@ -31,6 +32,7 @@ const RolesPage: React.FC = () => { const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); + const roleGuard = useDirtyGuard(form); const [selectedPermIds, setSelectedPermIds] = useState([]); const [saving, setSaving] = useState(false); @@ -89,6 +91,7 @@ const RolesPage: React.FC = () => { setEditing(null); form.resetFields(); setSelectedPermIds([]); + roleGuard.snapshot(); setModalOpen(true); }; @@ -97,9 +100,10 @@ const RolesPage: React.FC = () => { setEditing(record); form.setFieldsValue({ name: record.name, description: record.description }); setSelectedPermIds(record.permissions.map((p) => p.id)); + roleGuard.snapshot(); setModalOpen(true); }, - [form], + [form, roleGuard], ); const handleSubmit = async () => { @@ -332,7 +336,7 @@ const RolesPage: React.FC = () => { title={editing ? '编辑角色' : '新增角色'} open={modalOpen} onOk={handleSubmit} - onCancel={() => setModalOpen(false)} + onCancel={() => roleGuard.confirmClose(() => setModalOpen(false))} width={700} destroyOnHidden confirmLoading={saving} diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index 3f8b47c3..76fdb5e8 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -15,6 +15,7 @@ import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useDownload } from '../../hooks/useDownload'; import { selectArchiveRecords } from '../archive-view'; import { getErrorMessage } from '../../utils/error'; +import { useDirtyGuard } from '../../hooks/useDirtyGuard'; import { QueryErrorState } from '../../components/QueryState'; import { useRoomColumns, type BedItem, type LockerItem } from './RoomColumns'; import { RoomDetailArea } from './RoomModals'; @@ -40,6 +41,7 @@ const RoomsPage: React.FC = () => { const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [saving, setSaving] = useState(false); const [form] = Form.useForm(); + const roomGuard = useDirtyGuard(form); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerRoom, setDrawerRoom] = useState(null); const [beds, setBeds] = useState([]); @@ -49,7 +51,9 @@ const RoomsPage: React.FC = () => { const [lockerModalOpen, setLockerModalOpen] = useState(false); const [lockerEditing, setLockerEditing] = useState(null); const [bedForm] = Form.useForm(); + const bedGuard = useDirtyGuard(bedForm); const [lockerForm] = Form.useForm(); + const lockerGuard = useDirtyGuard(lockerForm); const [savingBed, setSavingBed] = useState(false); const [savingLocker, setSavingLocker] = useState(false); const [batchLoading, setBatchLoading] = useState(false); @@ -425,6 +429,7 @@ const RoomsPage: React.FC = () => { onEdit: (record) => { setEditing(record); form.setFieldsValue(record); + roomGuard.snapshot(); setModalOpen(true); }, }); @@ -458,6 +463,7 @@ const RoomsPage: React.FC = () => { onAddRoom={() => { setEditing(null); form.resetFields(); + roomGuard.snapshot(); setModalOpen(true); }} onImport={async (options: UploadRequestOption<{ message?: string }>) => { @@ -505,10 +511,12 @@ const RoomsPage: React.FC = () => { saving={saving} form={form} onSaveRoom={handleSave} - onCloseRoomModal={() => { - setModalOpen(false); - setEditing(null); - }} + onCloseRoomModal={() => + roomGuard.confirmClose(() => { + setModalOpen(false); + setEditing(null); + }) + } drawerOpen={drawerOpen} drawerRoom={drawerRoom} beds={beds} @@ -531,12 +539,14 @@ const RoomsPage: React.FC = () => { onAddBed={() => { setBedEditing(null); bedForm.resetFields(); + bedGuard.snapshot(); setBedModalOpen(true); }} onBatchBeds={handleBatchBeds} onEditBed={(r) => { setBedEditing(r); bedForm.setFieldsValue(r); + bedGuard.snapshot(); setBedModalOpen(true); }} onDeleteBed={handleDeleteBed} @@ -544,12 +554,14 @@ const RoomsPage: React.FC = () => { onAddLocker={() => { setLockerEditing(null); lockerForm.resetFields(); + lockerGuard.snapshot(); setLockerModalOpen(true); }} onBatchLockers={handleBatchLockers} onEditLocker={(r) => { setLockerEditing(r); lockerForm.setFieldsValue(r); + lockerGuard.snapshot(); setLockerModalOpen(true); }} onDeleteLocker={handleDeleteLocker} @@ -559,19 +571,23 @@ const RoomsPage: React.FC = () => { savingBed={savingBed} bedForm={bedForm} onSaveBed={handleSaveBed} - onCloseBedModal={() => { - setBedModalOpen(false); - setBedEditing(null); - }} + onCloseBedModal={() => + bedGuard.confirmClose(() => { + setBedModalOpen(false); + setBedEditing(null); + }) + } lockerModalOpen={lockerModalOpen} lockerEditing={!!lockerEditing} savingLocker={savingLocker} lockerForm={lockerForm} onSaveLocker={handleSaveLocker} - onCloseLockerModal={() => { - setLockerModalOpen(false); - setLockerEditing(null); - }} + onCloseLockerModal={() => + lockerGuard.confirmClose(() => { + setLockerModalOpen(false); + setLockerEditing(null); + }) + } />
); diff --git a/apps/admin/src/pages/Schedules/ScheduleModals.tsx b/apps/admin/src/pages/Schedules/ScheduleModals.tsx index 7ed58326..26cd9ae5 100644 --- a/apps/admin/src/pages/Schedules/ScheduleModals.tsx +++ b/apps/admin/src/pages/Schedules/ScheduleModals.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Alert, Button, @@ -28,6 +28,7 @@ import { isMaskedSchedule } from './schedule-visibility'; import type { ScheduleFormValues } from './schedule-form'; import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem } from './ScheduleGrids'; import { WEEKDAYS } from './ScheduleGrids'; +import { useDirtyGuard } from '../../hooks/useDirtyGuard'; export interface ScheduleModalProps { open: boolean; @@ -74,6 +75,12 @@ export const ScheduleModal: React.FC = ({ onClassChange, onSubjectBlur, }) => { + const scheduleGuard = useDirtyGuard(form); + // 弹窗打开或切换为创建/编辑模式时,父组件已完成表单回填,这里记录「未修改」基准 + useEffect(() => { + if (open && mode !== 'detail') scheduleGuard.snapshot(); + }, [open, mode, editingSchedule, scheduleGuard]); + const title = mode === 'create' ? `新增排课 — ${selectedClassroom?.name || ''} · ${ @@ -93,7 +100,7 @@ export const ScheduleModal: React.FC = ({ scheduleGuard.confirmClose(onCancel)} onOk={mode !== 'detail' ? onSubmit : undefined} confirmLoading={submitting} okText={mode === 'edit' ? '保存' : mode === 'create' ? '创建' : undefined} diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index 9ce4a076..844746d1 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -6,6 +6,7 @@ import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { QueryErrorState } from '../../components/QueryState'; +import { NextStepHint } from '../../components/NextStepHint'; import { usePermission } from '../../hooks/usePermission'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { message } from '../../ui/app-message'; @@ -61,6 +62,8 @@ const SchedulesPage: React.FC = () => { const [submitting, setSubmitting] = useState(false); const [syncModalOpen, setSyncModalOpen] = useState(false); const [syncing, setSyncing] = useState(false); + // 排课创建成功后的「下一步:同步钉钉」引导 + const [syncHint, setSyncHint] = useState(false); const [syncStatus, setSyncStatus] = useState<{ activeSchedules: number; mappedClasses: number; @@ -108,8 +111,10 @@ const SchedulesPage: React.FC = () => { message.error(classification.message); } else if (classification.level === 'warning') { message.warning(classification.message); + setSyncHint(false); } else { message.success(classification.message); + setSyncHint(false); } } catch (e: unknown) { message.error(getErrorMessage(e, '同步失败')); @@ -346,6 +351,7 @@ const SchedulesPage: React.FC = () => { message.success( modalMode === 'edit' ? '排课更新成功,请重新同步到钉钉排班' : '排课创建成功', ); + if (modalMode === 'create') setSyncHint(true); setModalOpen(false); setEditingSchedule(null); } catch { @@ -416,6 +422,14 @@ const SchedulesPage: React.FC = () => { return (
+ {syncHint && ( + void handleSyncSchedule() }} + onClose={() => setSyncHint(false)} + /> + )}
void; onClearSelection: () => void; + /** 空状态下的「添加学生」动作(无权限或不传则不显示) */ + onAddStudent?: () => void; + showArchived?: boolean; }> = ({ columns, data, @@ -31,6 +36,8 @@ export const StudentsTable: React.FC<{ selectedRowKeys, onSelect, onClearSelection, + onAddStudent, + showArchived, }) => { const [enrollmentData, setEnrollmentData] = React.useState>({}); @@ -59,7 +66,23 @@ export const StudentsTable: React.FC<{ dataSource={data} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + , + onClick: onAddStudent, + } + : undefined + } + /> + ), + }} scroll={{ x: 1410 }} pagination={{ defaultPageSize: 15, diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index cdf59b6c..731057e4 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -595,6 +595,18 @@ const StudentsPage: React.FC = () => { selectedRowKeys={selectedRowKeys} onSelect={setSelectedRowKeys} onClearSelection={() => setSelectedRowKeys([])} + showArchived={showArchived} + onAddStudent={ + canSaveStudent + ? () => { + setEditing(null); + form.resetFields(); + const host = organizations.find((organization) => organization.isHost); + if (host) form.setFieldValue('organizationId', host.id); + setModalOpen(true); + } + : undefined + } /> )} { const [search, setSearch] = useState(''); const [profileModal, setProfileModal] = useState(null); const [form] = Form.useForm(); + const profileFormGuard = useDirtyGuard(form); const [saving, setSaving] = useState(false); const { @@ -222,6 +224,7 @@ const TeachersPage: React.FC = () => { joinedAt: r.profile?.joinedAt ? dayjs(r.profile.joinedAt) : null, qualifications: r.profile?.qualifications || '', }); + profileFormGuard.snapshot(); }} > 档案 @@ -229,7 +232,7 @@ const TeachersPage: React.FC = () => { ), }, ], - [saveProfileCell, form], + [saveProfileCell, form, profileFormGuard], ); return ( @@ -295,7 +298,7 @@ const TeachersPage: React.FC = () => { title={`编辑档案 — ${profileModal?.name || ''}`} open={!!profileModal && canEditTeachers} onOk={canEditTeachers ? handleSaveProfile : undefined} - onCancel={() => setProfileModal(null)} + onCancel={() => profileFormGuard.confirmClose(() => setProfileModal(null))} okText="保存" confirmLoading={saving} > diff --git a/apps/admin/src/pages/Users/index.tsx b/apps/admin/src/pages/Users/index.tsx index a223bc77..97ee139c 100644 --- a/apps/admin/src/pages/Users/index.tsx +++ b/apps/admin/src/pages/Users/index.tsx @@ -16,6 +16,7 @@ import { userProfileResponseToFormValues, type UserProfileResponse } from './use import { usePermission } from '../../hooks/usePermission'; import { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; +import { useDirtyGuard } from '../../hooks/useDirtyGuard'; import { validateResponse } from '../../utils/validate'; import { rolesSchema, usersSchema } from '../../api/schemas'; import { getErrorMessage } from '../../utils/error'; @@ -44,6 +45,11 @@ const UsersPage: React.FC = () => { const [saving, setSaving] = useState(false); const [showArchived, setShowArchived] = useState(false); + // 弹窗「未保存内容」保护 + const accountGuard = useDirtyGuard(form); + const pwdGuard = useDirtyGuard(pwdForm); + const profileGuard = useDirtyGuard(profileForm); + const handleOpenProfile = useCallback( async (record: any) => { setProfileUser(record); @@ -53,9 +59,10 @@ const UsersPage: React.FC = () => { } catch { profileForm.setFieldsValue({}); } + profileGuard.snapshot(); setProfileModalOpen(true); }, - [profileForm], + [profileForm, profileGuard], ); const handleProfileSubmit = async () => { @@ -133,6 +140,7 @@ const UsersPage: React.FC = () => { const handleAdd = () => { setEditing(null); form.resetFields(); + accountGuard.snapshot(); setModalOpen(true); }; @@ -144,9 +152,10 @@ const UsersPage: React.FC = () => { name: record.name, roleIds: record.roles?.map((r: any) => r.id) || [], }); + accountGuard.snapshot(); setModalOpen(true); }, - [form], + [accountGuard, form], ); const handleSubmit = async () => { @@ -205,9 +214,10 @@ const UsersPage: React.FC = () => { (record: any) => { setResetTarget(record); pwdForm.resetFields(); + pwdGuard.snapshot(); setPwdModalOpen(true); }, - [pwdForm], + [pwdForm, pwdGuard], ); const handlePwdSubmit = async () => { @@ -438,7 +448,7 @@ const UsersPage: React.FC = () => { title={editing ? '编辑账号' : '新增账号'} open={modalOpen} onOk={handleSubmit} - onCancel={() => setModalOpen(false)} + onCancel={() => accountGuard.confirmClose(() => setModalOpen(false))} destroyOnHidden confirmLoading={saving} > @@ -485,7 +495,7 @@ const UsersPage: React.FC = () => { title={`重置密码 - ${resetTarget?.username}`} open={pwdModalOpen} onOk={handlePwdSubmit} - onCancel={() => setPwdModalOpen(false)} + onCancel={() => pwdGuard.confirmClose(() => setPwdModalOpen(false))} destroyOnHidden confirmLoading={saving} > @@ -504,7 +514,7 @@ const UsersPage: React.FC = () => { title={`教师档案 - ${profileUser?.name || profileUser?.username}`} open={profileModalOpen} onOk={handleProfileSubmit} - onCancel={() => setProfileModalOpen(false)} + onCancel={() => profileGuard.confirmClose(() => setProfileModalOpen(false))} destroyOnHidden confirmLoading={saving} > From 1b4ba893fdd63d64461f02310111595afdc39751 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 18:00:29 +0800 Subject: [PATCH 07/43] =?UTF-8?q?feat:=20=E7=A9=BA=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=BC=95=E5=AF=BC=E5=85=A8=E9=9D=A2=E5=BA=94=E7=94=A8=E4=B8=8E?= =?UTF-8?q?=E8=80=83=E5=8B=A4=E6=89=B9=E9=87=8F=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin: - 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/ 教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等, 有创建权限的页面附带主操作按钮,无权限时纯展示 - 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮: 仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量 server: - 新增 PUT /attendance-records/batch-status 批量改状态接口 (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds, 审计日志记录批量结果;路由声明在 :id 之前避免被捕获) aislop scan: 5 引擎 0 issues --- .../Attendance/LessonAttendanceDetail.tsx | 72 ++++++++++++++++++- apps/admin/src/pages/AttendanceDevices.tsx | 19 ++++- apps/admin/src/pages/Bills/index.tsx | 26 +++++-- apps/admin/src/pages/Classes/index.tsx | 16 ++++- .../pages/ClassroomRentals/RentalTable.tsx | 4 +- .../src/pages/ClassroomSchedule/index.tsx | 5 +- apps/admin/src/pages/Classrooms/index.tsx | 30 +++++--- .../admin/src/pages/Deposits/DepositTable.tsx | 20 +++++- apps/admin/src/pages/Deposits/index.tsx | 14 ++-- apps/admin/src/pages/Exams/index.tsx | 12 +++- .../src/pages/Expenses/ExpenseTablePanel.tsx | 15 +++- apps/admin/src/pages/Notifications/index.tsx | 6 +- .../Occupancies/OccupanciesTableArea.tsx | 20 +++++- apps/admin/src/pages/Occupancies/index.tsx | 34 +++++---- apps/admin/src/pages/Organizations/index.tsx | 16 ++++- apps/admin/src/pages/Roles/index.tsx | 18 ++++- apps/admin/src/pages/Rooms/RoomsTable.tsx | 20 +++++- apps/admin/src/pages/Rooms/index.tsx | 16 +++-- .../src/pages/Schedules/ScheduleGrids.tsx | 5 +- apps/admin/src/pages/Teachers/index.tsx | 3 +- apps/admin/src/pages/Wallets/index.tsx | 3 +- .../attendance-records.controller.ts | 31 ++++++++ .../src/attendance/dto/attendance.dto.ts | 22 ++++++ 23 files changed, 350 insertions(+), 77 deletions(-) diff --git a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx index 4075ac73..c6e64b8f 100644 --- a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx +++ b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd'; +import { Alert, App, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd'; import dayjs from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; @@ -79,6 +79,7 @@ const LessonAttendanceDetail: React.FC = ({ className, onClose, }) => { + const { modal } = App.useApp(); const { hasAnyPermission } = usePermission(); const canEditAttendance = hasAnyPermission('attendance:edit', 'attendance:self-edit'); const [loadedSchedule, setLoadedSchedule] = useState(null); @@ -88,9 +89,58 @@ const LessonAttendanceDetail: React.FC = ({ const [error, setError] = useState(null); const [keyword, setKeyword] = useState(''); const [filter, setFilter] = useState('all'); + const [batchUpdating, setBatchUpdating] = useState<'present' | 'absent' | null>(null); // 组件以 key 重挂载(关闭/切换课节),卸载后 in-flight 请求不再更新状态或弹提示 const cancelledRef = useRef(false); + /** 一键全部已打卡/全部未打卡(仅对状态不一致的记录) */ + const handleBatchMark = async (status: 'present' | 'absent') => { + if (batchUpdating || records.length === 0) return; + const targetIds = records + .filter((record) => + status === 'present' + ? record.status !== 'present' && record.status !== 'late' + : record.status !== 'absent', + ) + .map((record) => record.id); + if (targetIds.length === 0) { + message.success(status === 'present' ? '所有学生都已打卡' : '所有学生都未打卡'); + return; + } + modal.confirm({ + title: status === 'present' ? `将 ${targetIds.length} 名学生标记为已打卡?` : `将 ${targetIds.length} 名学生标记为未打卡?`, + content: + '此操作会立即写入考勤记录;已结算(课程截止后)的记录无法修改。', + okText: '确认', + cancelText: '取消', + onOk: async () => { + setBatchUpdating(status); + try { + const res = await api.put<{ updated: number; failed: number; failedIds: number[] }>( + '/attendance-records/batch-status', + { ids: targetIds, status }, + ); + if (cancelledRef.current) return; + const failedSet = new Set(res.failedIds); + setRecords((items) => + items.map((record) => + targetIds.includes(record.id) && !failedSet.has(record.id) + ? { ...record, status } + : record, + ), + ); + message.success(`已更新 ${res.updated} 条记录`); + if (res.failed > 0) message.warning(`有 ${res.failed} 条更新失败(可能已结算)`); + } catch (error: unknown) { + if (cancelledRef.current) return; + message.error(getErrorMessage(error, '批量更新失败')); + } finally { + if (!cancelledRef.current) setBatchUpdating(null); + } + }, + }); + }; + const loadLesson = useCallback(async () => { if (!schedule) return; setLoading(true); @@ -192,6 +242,26 @@ const LessonAttendanceDetail: React.FC = ({ 显示 {filteredRecords.length} / {records.length} 人 + {canEditAttendance && records.length > 0 ? ( + <> + + + + ) : null}
{error ? ( { + const { hasPermission } = usePermission(); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [saving, setSaving] = useState(false); @@ -321,7 +323,18 @@ const AttendanceDevicesPage: React.FC = () => { columns={columns} dataSource={filteredData} loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + , onClick: openCreate } + : undefined + } + /> + ), + }} pagination={{ defaultPageSize: 20, showSizeChanger: true }} /> )} diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index e7937f78..e5bf3a06 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -13,7 +13,6 @@ import { Input, Select, Spin, - Empty, } from 'antd'; import { FileTextOutlined, @@ -24,7 +23,7 @@ import { import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { NextStepHint } from '../../components/NextStepHint'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useDownload } from '../../hooks/useDownload'; @@ -144,6 +143,11 @@ const BillsPage: React.FC = () => { } }; + const openGenerateModal = () => { + generateForm.resetFields(); + setGenerateModal(true); + }; + const showDetail = useCallback(async (id: number) => { setDetailLoading(true); try { @@ -461,10 +465,7 @@ const BillsPage: React.FC = () => { permission="bill:generate" type="primary" icon={} - onClick={() => { - generateForm.resetFields(); - setGenerateModal(true); - }} + onClick={openGenerateModal} > 生成账单 @@ -507,7 +508,18 @@ const BillsPage: React.FC = () => { rowKey="id" loading={loading} pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }} - locale={{ emptyText: }} + locale={{ + emptyText: ( + + ), + }} rowSelection={{ selectedRowKeys: selectedRows, onChange: (keys) => setSelectedRows(keys as number[]), diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index 05e6c079..ec589725 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -19,7 +19,6 @@ import { Popconfirm, Card, Switch, - Empty, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons'; @@ -30,7 +29,7 @@ import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; @@ -447,7 +446,18 @@ const ClassesPage: React.FC = () => { dataSource={filtered} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + , onClick: handleCreate } + : undefined + } + /> + ), + }} pagination={{ defaultPageSize: 20, showSizeChanger: true, diff --git a/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx index c3d7dcde..6c90a4bc 100644 --- a/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx +++ b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import { Button, - Empty, Popconfirm, Space, Table, @@ -19,6 +18,7 @@ import dayjs from 'dayjs'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; +import { QueryEmpty } from '../../components/QueryState'; const RENTAL_FIELDS = { classroomId: 'classroomId', @@ -345,7 +345,7 @@ export const RentalTable: React.FC = ({ dataSource={data} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ emptyText: }} pagination={{ defaultPageSize: 15, showSizeChanger: true, diff --git a/apps/admin/src/pages/ClassroomSchedule/index.tsx b/apps/admin/src/pages/ClassroomSchedule/index.tsx index ff494a45..8d478edc 100644 --- a/apps/admin/src/pages/ClassroomSchedule/index.tsx +++ b/apps/admin/src/pages/ClassroomSchedule/index.tsx @@ -13,7 +13,6 @@ import { Button, Modal, Spin, - Empty, Tooltip, } from 'antd'; import { CalendarOutlined, FileTextOutlined, ReadOutlined } from '@ant-design/icons'; @@ -22,7 +21,7 @@ import api from '../../api'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; import { getErrorMessage } from '../../utils/error'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; interface ScheduleData { @@ -197,7 +196,7 @@ const ClassroomSchedulePage: React.FC = () => { {!data || data.classrooms.length === 0 ? ( - + ) : (
{groups.map((group) => ( diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index 711a3de4..94156a5e 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -17,7 +17,6 @@ import { Popconfirm, Upload, Tooltip, - Empty, } from 'antd'; import { PlusOutlined, @@ -29,7 +28,7 @@ import { import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { useUserStore } from '../../store/user/userStore'; @@ -147,6 +146,13 @@ const ClassroomsPage: React.FC = () => { } }; + const openCreateModal = () => { + setEditing(null); + form.resetFields(); + formGuard.snapshot(); + setModalOpen(true); + }; + const saveCell = useCallback( async (record: any, field: string, value: unknown) => { try { @@ -461,12 +467,7 @@ const ClassroomsPage: React.FC = () => { permission="classroom:create" type="primary" icon={} - onClick={() => { - setEditing(null); - form.resetFields(); - formGuard.snapshot(); - setModalOpen(true); - }} + onClick={openCreateModal} > 添加教室 @@ -531,7 +532,18 @@ const ClassroomsPage: React.FC = () => { dataSource={filteredData} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + + ), + }} pagination={{ defaultPageSize: 20, showSizeChanger: true, diff --git a/apps/admin/src/pages/Deposits/DepositTable.tsx b/apps/admin/src/pages/Deposits/DepositTable.tsx index e0db067d..6541dde7 100644 --- a/apps/admin/src/pages/Deposits/DepositTable.tsx +++ b/apps/admin/src/pages/Deposits/DepositTable.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Button, Empty, Popconfirm, Space, Table, Tag } from 'antd'; +import { Button, Popconfirm, Space, Table, Tag } from 'antd'; import { DeleteOutlined, InboxOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import PermissionButton from '../../components/PermissionButton'; +import { QueryEmpty } from '../../components/QueryState'; import { message } from '../../ui/app-message'; import { statusMap } from './DepositModals'; import type { DepositRecord } from './DepositModals'; @@ -11,6 +12,8 @@ export interface DepositTableProps { data: any[]; loading: boolean; canPurgeDeposit: boolean; + canCreateDeposit?: boolean; + onCreateDeposit?: () => void; refundForm: ReturnType[0]; onDetail: (record: DepositRecord) => void; onRefund: (record: DepositRecord) => void; @@ -22,6 +25,8 @@ export const DepositTable: React.FC = ({ data, loading, canPurgeDeposit, + canCreateDeposit, + onCreateDeposit, refundForm, onDetail, onRefund, @@ -142,7 +147,18 @@ export const DepositTable: React.FC = ({ pageSizeOptions: [15, 30, 50, 100], showTotal: (total) => `共 ${total} 条`, }} - locale={{ emptyText: }} + locale={{ + emptyText: ( + + ), + }} /> ); }; diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index f05b9ced..989bbe9b 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -225,6 +225,12 @@ const DepositsPage: React.FC = () => { setBatchModal(true); }; + const openCreateDeposit = () => { + createForm.resetFields(); + createForm.setFieldsValue({ amount: 500, paidDate: dayjs() }); + setCreateModal(true); + }; + const handleBatchRoomTypeChange = (roomType: string) => { setBatchRoomType(roomType); // 切换房型后候选学生列表会变化,重置勾选状态,避免把上一房型的选择提交到新房型 @@ -431,11 +437,7 @@ const DepositsPage: React.FC = () => { permission="deposit:create" type="primary" icon={} - onClick={() => { - createForm.resetFields(); - createForm.setFieldsValue({ amount: 500, paidDate: dayjs() }); - setCreateModal(true); - }} + onClick={openCreateDeposit} > 收取押金 @@ -458,6 +460,8 @@ const DepositsPage: React.FC = () => { data={filteredData} loading={loading || (!!filterRoomType && eligibleLoading)} canPurgeDeposit={canPurgeDeposit} + canCreateDeposit={hasPermission('deposit:create')} + onCreateDeposit={openCreateDeposit} refundForm={refundForm} onDetail={(record) => setDetailModal(record)} onRefund={(record) => setRefundModal(record)} diff --git a/apps/admin/src/pages/Exams/index.tsx b/apps/admin/src/pages/Exams/index.tsx index d38b5537..6cad3152 100644 --- a/apps/admin/src/pages/Exams/index.tsx +++ b/apps/admin/src/pages/Exams/index.tsx @@ -6,7 +6,6 @@ import { Card, Checkbox, Col, - Empty, Form, Input, Popconfirm, @@ -39,7 +38,7 @@ import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { classOptionsSchema, examsSchema } from '../../api/schemas'; import { getErrorMessage } from '../../utils/error'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { NextStepHint } from '../../components/NextStepHint'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; @@ -371,7 +370,14 @@ const ExamsPage: React.FC = () => { /> ) : data.length === 0 && !loading ? (
- + , onClick: openCreate } + : undefined + } + />
) : ( diff --git a/apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx b/apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx index efaffe51..505b8817 100644 --- a/apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx +++ b/apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Button, - Empty, Input, Popconfirm, Select, @@ -24,6 +23,7 @@ import { import dayjs from 'dayjs'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; +import { QueryEmpty } from '../../components/QueryState'; import { message } from '../../ui/app-message'; export const EXPENSE_FIELDS = { @@ -519,7 +519,18 @@ export const ExpenseTablePanel: React.FC = ({ pageSizeOptions: [15, 30, 50, 100], showTotal: (total) => `共 ${total} 条`, }} - locale={{ emptyText: }} + locale={{ + emptyText: ( + + ), + }} rowSelection={{ selectedRowKeys: selectedKeys, onChange: (keys) => onSelect(keys as number[]), diff --git a/apps/admin/src/pages/Notifications/index.tsx b/apps/admin/src/pages/Notifications/index.tsx index b3dcb045..ab85cf49 100644 --- a/apps/admin/src/pages/Notifications/index.tsx +++ b/apps/admin/src/pages/Notifications/index.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { validateResponse } from '../../utils/validate'; import { notificationsSchema } from '../../api/schemas'; -import { List, Typography, Menu, Layout, Button, Empty, Spin, Space, Grid, Select } from 'antd'; +import { List, Typography, Menu, Layout, Button, Spin, Space, Grid, Select } from 'antd'; import { BellOutlined, DollarOutlined, @@ -13,7 +13,7 @@ import { useNavigate } from 'react-router'; import api from '../../api'; import { message } from '../../ui/app-message'; import { formatNotificationText } from '../../utils/notification-display'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; const { Sider, Content } = Layout; const { useBreakpoint } = Grid; @@ -174,7 +174,7 @@ const NotificationsPage: React.FC = () => { ) : ( {filtered.length === 0 ? ( - + ) : ( void; batchLoading: boolean; onBatchCheckOut: () => void; onBatchDelete: () => void; @@ -27,6 +30,8 @@ export const OccupanciesTableArea: React.FC<{ batchAction, canDelete, canPurge, + canCheckIn, + onCheckIn, batchLoading, onBatchCheckOut, onBatchDelete, @@ -127,7 +132,18 @@ export const OccupanciesTableArea: React.FC<{ dataSource={data} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + + ), + }} scroll={{ x: 1300 }} pagination={{ defaultPageSize: 15, diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index ad9555cf..5d380864 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -456,6 +456,22 @@ const OccupanciesPage: React.FC = () => { const { downloading: templateDownloading, run: runTemplateDownload } = useDownload(); const { downloading: exportDownloading, run: runExportDownload } = useDownload(); + const openCheckInModal = () => { + checkInForm.resetFields(); + setAvailableBeds([]); + setAvailableLockers([]); + setAvailableResourcesLoading(false); + const today = dayjs(); + checkInForm.setFieldsValue({ + checkInDate: today, + billingStartDate: today, + stayType: 'short', + collectDeposit: true, + depositAmount: 500, + }); + setCheckInModal(true); + }; + return (
{ dateRange={dateRange} onChangeDateRange={changeDateRange} canCheckIn={canCheckIn} - onCheckIn={() => { - checkInForm.resetFields(); - setAvailableBeds([]); - setAvailableLockers([]); - setAvailableResourcesLoading(false); - const today = dayjs(); - checkInForm.setFieldsValue({ - checkInDate: today, - billingStartDate: today, - stayType: 'short', - collectDeposit: true, - depositAmount: 500, - }); - setCheckInModal(true); - }} + onCheckIn={openCheckInModal} onImport={async ({ file, onSuccess, onError }: any) => { const formData = new FormData(); formData.append('file', file); @@ -557,6 +559,8 @@ const OccupanciesPage: React.FC = () => { batchAction={viewPolicy.batchAction} canDelete={canDelete} canPurge={canPurge} + canCheckIn={canCheckIn} + onCheckIn={openCheckInModal} batchLoading={batchLoading} onBatchCheckOut={() => { batchCheckOutForm.resetFields(); diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index ca39730e..28479876 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -1,7 +1,7 @@ // aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 import React, { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { App, Alert, Button, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; +import { App, Alert, Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; @@ -12,6 +12,7 @@ import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { organizationsSchema } from '../../api/schemas'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; +import { QueryEmpty } from '../../components/QueryState'; const PRESET_COLORS = [ '#ff7875', @@ -398,7 +399,18 @@ const OrganizationsPage: React.FC = () => { dataSource={filteredData} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + , onClick: () => openEditor() } + : undefined + } + /> + ), + }} scroll={{ x: 1100 }} pagination={{ defaultPageSize: 20, diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index 2e113785..4d51097a 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo, useCallback } from 'react'; -import { Table, Modal, Form, Input, Space, Tag, Popconfirm, Card, Checkbox, Empty } from 'antd'; +import { Table, Modal, Form, Input, Space, Tag, Popconfirm, Card, Checkbox } from 'antd'; import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; @@ -11,6 +11,8 @@ import { validateResponse } from '../../utils/validate'; import { permissionTreeSchema, rolesSchema } from '../../api/schemas'; import { getErrorMessage } from '../../utils/error'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; +import { usePermission } from '../../hooks/usePermission'; +import { QueryEmpty } from '../../components/QueryState'; interface PermissionItem { id: number; @@ -29,6 +31,7 @@ interface RoleItem { } const RolesPage: React.FC = () => { + const { hasPermission } = usePermission(); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); @@ -327,7 +330,18 @@ const RolesPage: React.FC = () => { dataSource={data} rowKey="id" loading={loading} - locale={{ emptyText: }} + locale={{ + emptyText: ( + , onClick: handleAdd } + : undefined + } + /> + ), + }} scroll={{ x: 900 }} pagination={false} /> diff --git a/apps/admin/src/pages/Rooms/RoomsTable.tsx b/apps/admin/src/pages/Rooms/RoomsTable.tsx index eccc02cf..b10a6146 100644 --- a/apps/admin/src/pages/Rooms/RoomsTable.tsx +++ b/apps/admin/src/pages/Rooms/RoomsTable.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Empty, Table } from 'antd'; +import { Table } from 'antd'; +import { QueryEmpty } from '../../components/QueryState'; export const RoomsTable: React.FC<{ columns: any[]; @@ -7,7 +8,9 @@ export const RoomsTable: React.FC<{ loading: boolean; selectedRowKeys: number[]; onSelect: (keys: number[]) => void; -}> = ({ columns, data, loading, selectedRowKeys, onSelect }) => { + canCreateRoom?: boolean; + onCreateRoom?: () => void; +}> = ({ columns, data, loading, selectedRowKeys, onSelect, canCreateRoom, onCreateRoom }) => { return ( <>
}} + locale={{ + emptyText: ( + + ), + }} pagination={{ defaultPageSize: 20, showSizeChanger: true, diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index 76fdb5e8..b0220252 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -182,6 +182,13 @@ const RoomsPage: React.FC = () => { } }; + const openAddRoomModal = () => { + setEditing(null); + form.resetFields(); + roomGuard.snapshot(); + setModalOpen(true); + }; + const saveRoomCell = useCallback( async (record: any, field: string, value: unknown) => { try { @@ -460,12 +467,7 @@ const RoomsPage: React.FC = () => { onBatchRestore={handleBatchRestore} onBatchPurge={handleBatchPurge} onBatchDelete={handleBatchDelete} - onAddRoom={() => { - setEditing(null); - form.resetFields(); - roomGuard.snapshot(); - setModalOpen(true); - }} + onAddRoom={openAddRoomModal} onImport={async (options: UploadRequestOption<{ message?: string }>) => { const { file, onSuccess, onError } = options; if (typeof file === 'string') { @@ -501,6 +503,8 @@ const RoomsPage: React.FC = () => { loading={loading} selectedRowKeys={selectedRowKeys} onSelect={setSelectedRowKeys} + canCreateRoom={canCreateRooms} + onCreateRoom={openAddRoomModal} /> )} diff --git a/apps/admin/src/pages/Schedules/ScheduleGrids.tsx b/apps/admin/src/pages/Schedules/ScheduleGrids.tsx index 44ee5b65..1234352e 100644 --- a/apps/admin/src/pages/Schedules/ScheduleGrids.tsx +++ b/apps/admin/src/pages/Schedules/ScheduleGrids.tsx @@ -1,6 +1,7 @@ // aislop-ignore-file: duplicate-block -- 周/月视图表格结构相似且展示维度不同,已共享 ScheduleGrid 组件 import React from 'react'; -import { Badge, Empty, Spin, Tooltip } from 'antd'; +import { Badge, Spin, Tooltip } from 'antd'; +import { QueryEmpty } from '../../components/QueryState'; import type { Dayjs } from 'dayjs'; import { isMaskedSchedule } from './schedule-visibility'; @@ -76,7 +77,7 @@ export const ScheduleGrid: React.FC<{ return ( {classrooms.length === 0 ? ( - + ) : viewMode === 'week' ? (
{ dataSource={data} rowKey="id" loading={loading} + locale={{ emptyText: }} scroll={{ x: 1300 }} pagination={{ current: page, diff --git a/apps/admin/src/pages/Wallets/index.tsx b/apps/admin/src/pages/Wallets/index.tsx index 2e0469f6..db5c5616 100644 --- a/apps/admin/src/pages/Wallets/index.tsx +++ b/apps/admin/src/pages/Wallets/index.tsx @@ -23,7 +23,7 @@ import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; import { newOperationId } from '../../utils/operation-id'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; interface WalletRow { @@ -328,6 +328,7 @@ const WalletsPage: React.FC = () => {
}} dataSource={rows} columns={columns} rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} diff --git a/apps/server/src/attendance/attendance-records.controller.ts b/apps/server/src/attendance/attendance-records.controller.ts index ceca7ddf..fd97615e 100644 --- a/apps/server/src/attendance/attendance-records.controller.ts +++ b/apps/server/src/attendance/attendance-records.controller.ts @@ -17,6 +17,7 @@ import { AttendanceReportQueryDto, AttendanceAlertsQueryDto, UpdateAttendanceRecordDto, + BatchUpdateAttendanceStatusDto, GenerateFromSchedulesDto, RefreshDingTalkAttendanceDto, } from './dto/attendance.dto'; @@ -232,6 +233,36 @@ export class AttendanceRecordsController extends AttendanceControllerBase { return this.service.findAll(query, await this.getAccessibleClassIds(req)); } + // ── Batch update attendance record statuses (纠错/点名) ── + // 注意:必须在 :id 路由之前声明,否则 "batch-status" 会被 :id(ParseIntPipe) 捕获 + @Put('attendance-records/batch-status') + @RequirePermission('attendance:edit', 'attendance:self-edit') + async batchUpdateStatus( + @Body() dto: BatchUpdateAttendanceStatusDto, + @Request() req: any, + ) { + const failedIds: number[] = []; + let updated = 0; + for (const id of dto.ids) { + try { + const existing = await this.service.findAttendanceRecord(id); + if (existing.classId == null && !this.canManageAllAttendance(req)) { + throw new ForbiddenException('无权修改未关联班级的考勤记录'); + } + if (existing.classId != null) await this.assertClassAccess(req, existing.classId); + await this.service.update(id, { status: dto.status, remark: dto.remark }); + updated += 1; + } catch { + failedIds.push(id); + } + } + await logAudit(this.logService, req, { + module: '考勤管理', action: '批量修改考勤状态', targetId: 0, targetType: 'attendanceRecord', + detail: `批量 ${dto.ids.length} 条 → ${dto.status},成功 ${updated},失败 ${failedIds.length}`, + }); + return { updated, failed: failedIds.length, failedIds }; + } + // ── Update a single attendance record ── @Put('attendance-records/:id') @RequirePermission('attendance:edit', 'attendance:self-edit') diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts index cf55fec8..43b9469e 100644 --- a/apps/server/src/attendance/dto/attendance.dto.ts +++ b/apps/server/src/attendance/dto/attendance.dto.ts @@ -9,8 +9,11 @@ import { ValidateNested, IsNotEmpty, ArrayNotEmpty, + ArrayMinSize, + ArrayMaxSize, Matches, Max, + MaxLength, Min, } from 'class-validator'; import { Type } from 'class-transformer'; @@ -248,6 +251,25 @@ export class UpdateAttendanceRecordDto { remark?: string; } +export class BatchUpdateAttendanceStatusDto { + /** 考勤记录 ID 列表(最多 200 条) */ + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(200) + @IsInt({ each: true }) + @Min(1, { each: true }) + ids: number[]; + + @IsString() + @IsIn(['present', 'late', 'absent', 'leave']) + status: string; + + @IsOptional() + @IsString() + @MaxLength(200) + remark?: string; +} + export class AttendanceAlertsQueryDto { @IsOptional() From 8b5fa98028dd38fbbe2367526a9b331aa401beb2 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 18:03:22 +0800 Subject: [PATCH 08/43] =?UTF-8?q?feat(admin):=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E8=80=83=E5=8B=A4=E6=98=8E=E7=BB=86=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E7=BA=A0=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原始考勤明细表支持行选择(跨页保留),选中后出现 「标记正常/标记请假/标记缺勤」批量操作条 - 批量提交前确认,成功后按 updated/failed 数量反馈, 失败记录(如已结算)提示原因;筛选条件变化自动清空选择 - 复用 PUT /attendance-records/batch-status 接口 aislop scan: 5 引擎 0 issues --- .../Attendance/AttendanceAdminWorkspace.tsx | 50 ++++++++++++++++++- apps/admin/src/pages/Attendance/admin.tsx | 45 ++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx b/apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx index ae8c78f8..4c687610 100644 --- a/apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx +++ b/apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Card, Empty, Input, Spin, Table } from 'antd'; +import { Button, Card, Empty, Input, Spin, Table } from 'antd'; import { ExportOutlined } from '@ant-design/icons'; import PermissionButton from '../../components/PermissionButton'; import { @@ -27,6 +27,11 @@ export const AttendanceAdminWorkspace: React.FC<{ pageSize: number; total: number; onPageChange: (page: number, pageSize: number) => void; + /** 明细表批量纠错 */ + selectedRecordKeys: number[]; + onSelectRecords: (keys: number[]) => void; + onBatchCorrect: (status: string) => void; + batchCorrecting: boolean; }> = ({ metricFilter, studentSearch, @@ -44,6 +49,10 @@ export const AttendanceAdminWorkspace: React.FC<{ pageSize, total, onPageChange, + selectedRecordKeys, + onSelectRecords, + onBatchCorrect, + batchCorrecting, }) => { return ( <> @@ -135,12 +144,51 @@ export const AttendanceAdminWorkspace: React.FC<{ + {selectedRecordKeys.length > 0 ? ( +
+ 已选 {selectedRecordKeys.length} 条 + + + + +
+ ) : null} rowKey="id" columns={columns} dataSource={records} loading={loading} scroll={{ x: 'max-content' }} + rowSelection={{ + selectedRowKeys: selectedRecordKeys, + onChange: (keys) => onSelectRecords(keys as number[]), + }} pagination={{ current: page, pageSize, diff --git a/apps/admin/src/pages/Attendance/admin.tsx b/apps/admin/src/pages/Attendance/admin.tsx index 1306fcdb..610062a6 100644 --- a/apps/admin/src/pages/Attendance/admin.tsx +++ b/apps/admin/src/pages/Attendance/admin.tsx @@ -1,7 +1,8 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; +import { getErrorMessage } from '../../utils/error'; import { attendanceAlertsSchema, attendanceClassOptionsSchema, @@ -57,6 +58,9 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit const [studentSearch, setStudentSearch] = useState(''); const [selectedStudent, setSelectedStudent] = useState(null); const [correctingRecordId, setCorrectingRecordId] = useState(null); + // 明细表批量纠错:选中的记录 ID + 执行中状态 + const [selectedRecordIds, setSelectedRecordIds] = useState([]); + const [batchCorrecting, setBatchCorrecting] = useState(false); const queryClient = useQueryClient(); useVisibleRefetch(['attendance', 'records']); const recordQueryKey = [ @@ -71,6 +75,11 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit scheduleId, ] as const; + // 筛选条件变化时清空批量选择,避免把上一批条件的记录带到新条件下提交 + useEffect(() => { + setSelectedRecordIds([]); + }, [classId, attendanceDate, status, session, scheduleId]); + const { data: classOptions = [] } = useQuery({ queryKey: ['attendance', 'meta', 'classes'], queryFn: async () => @@ -400,6 +409,36 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit } }; + /** 批量纠错:对选中的明细记录统一标记状态 */ + const batchCorrectStatus = async (nextStatus: string) => { + if (selectedRecordIds.length === 0) return; + const statusLabel = + nextStatus === 'present' ? '正常' : nextStatus === 'leave' ? '请假' : '缺勤'; + modal.confirm({ + title: `将选中的 ${selectedRecordIds.length} 条记录标记为「${statusLabel}」?`, + content: '此操作会立即写入考勤记录;已结算的记录无法修改。', + okText: '确认', + cancelText: '取消', + onOk: async () => { + setBatchCorrecting(true); + try { + const res = await api.put<{ updated: number; failed: number; failedIds: number[] }>( + '/attendance-records/batch-status', + { ids: selectedRecordIds, status: nextStatus }, + ); + setSelectedRecordIds([]); + message.success(`已更新 ${res.updated} 条记录`); + if (res.failed > 0) message.warning(`有 ${res.failed} 条更新失败(可能已结算)`); + void refetchRecords(); + } catch (e: unknown) { + message.error(getErrorMessage(e, '批量更新失败')); + } finally { + setBatchCorrecting(false); + } + }, + }); + }; + const saveAdminRecordCell = async ( record: AttendanceRecordItem, field: 'status' | 'remark', @@ -544,6 +583,10 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit setPage(nextPage); setPageSize(nextPageSize); }} + selectedRecordKeys={selectedRecordIds} + onSelectRecords={setSelectedRecordIds} + onBatchCorrect={batchCorrectStatus} + batchCorrecting={batchCorrecting} /> )} From a644a8de4221903cb2515f209c52ee779df8d09f Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 09:28:23 +0800 Subject: [PATCH 09/43] =?UTF-8?q?refactor(server):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=85=A8=E9=87=8F=20any=20=E7=B1=BB=E5=9E=8B=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=20(692=20=E2=86=92=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 全模块类型化:controller 的 req: any → AuthenticatedRequest/RequestUser, 聚合查询 getRawMany 泛型标注、导入行/响应体定义具体 interface、 catch (e: any) → unknown + 收窄、no-base-to-string 用 String() 显式转换 - 第三方无类型库边界(pdfkit/exceljs)文件级或单行 disable 并注明理由 - 顺带修复:get-business-context.tool 两个 require-await error、 bills.controller 参数顺序隐患、main.ts compression 调用 - 运行时逻辑零改动;测试 142 套件 / 1065 用例全部通过 --- .../get-business-context.tool.ts | 20 ++-- .../agent-context/get-pending-tasks.tool.ts | 2 +- .../agent-context/pending-tasks.service.ts | 4 +- .../agent-tools/tools/create-student.tool.ts | 7 +- .../agent-tools/tools/update-students.tool.ts | 5 +- .../src/ai-chat/ai-chat.service.spec.ts | 1 - apps/server/src/ai-chat/ai-chat.service.ts | 4 +- apps/server/src/ai-chat/ai-chat.streaming.ts | 4 +- .../server/src/ai-chat/ai-chat.submissions.ts | 6 +- .../ai-chat/ai-chat.tool-actions.import.ts | 10 +- .../src/ai-chat/ai-chat.tool-actions.ts | 8 +- apps/server/src/ai-chat/ai-chat.types.ts | 3 +- .../attendance-devices.controller.ts | 13 ++- .../attendance-import.controller.ts | 9 +- .../attendance-records.controller.ts | 17 ++-- apps/server/src/auth/auth.controller.ts | 15 ++- apps/server/src/auth/auth.service.ts | 2 +- apps/server/src/bills/bills-export.service.ts | 1 + apps/server/src/bills/bills.controller.ts | 27 +++-- apps/server/src/bills/bills.service.ts | 8 +- apps/server/src/classes/classes.controller.ts | 18 ++-- apps/server/src/classes/dto/class.dto.ts | 2 +- .../classroom-rentals.controller.ts | 22 +++-- .../classroom-rentals.service.ts | 15 ++- .../src/classrooms/classrooms.controller.ts | 41 ++++++-- .../src/classrooms/classrooms.service.ts | 23 ++++- apps/server/src/common/request-utils.ts | 10 +- .../dashboard/dashboard-queries.service.ts | 24 +++-- .../server/src/dashboard/dashboard.service.ts | 58 ++++++----- .../database-migrations.attendance.ts | 8 +- .../database/database-migrations.backfill.ts | 37 +++++-- .../src/deposits/deposits.controller.ts | 17 +++- apps/server/src/deposits/deposits.service.ts | 14 ++- .../entities/jinshuju-match-rule.entity.ts | 2 +- apps/server/src/exams/dto/exam.dto.ts | 2 +- .../expense-types/expense-types.controller.ts | 13 ++- .../expenses/expense-operations.service.ts | 18 ++-- .../src/expenses/expenses.controller.ts | 98 +++++++++++++------ apps/server/src/expenses/expenses.service.ts | 7 +- .../src/imports/imports.service.spec.ts | 1 - apps/server/src/imports/imports.service.ts | 2 +- .../src/imports/imports.workbook.spec.ts | 2 +- .../config/integration-config.service.ts | 35 +++++-- apps/server/src/integration/wecom.service.ts | 6 +- apps/server/src/main.ts | 1 + .../1786000000000-WidenImportSheetsJson.ts | 4 +- .../src/occupancies/occupancies.controller.ts | 26 +++-- .../occupancies/occupancy-import-template.ts | 1 + .../occupancies/occupancy-import.service.ts | 21 +++- .../operation-logs.controller.ts | 9 +- .../organizations/organizations.controller.ts | 15 ++- apps/server/src/rbac/rbac.controller.ts | 53 +++++----- apps/server/src/rooms/room-query.service.ts | 49 ++++++++-- apps/server/src/rooms/rooms.controller.ts | 43 +++++--- apps/server/src/rooms/rooms.service.ts | 8 +- .../src/schedules/schedule-queries.service.ts | 25 +++-- apps/server/src/students/dto/student.dto.ts | 2 +- apps/server/src/students/student-import.ts | 7 +- .../src/students/students.agent.service.ts | 55 ++++++----- .../src/students/students.controller.ts | 30 +++--- apps/server/src/sync/jinshuju-rules.ts | 5 +- apps/server/src/sync/sync.service.ts | 22 ++++- apps/server/src/wallets/wallets.controller.ts | 11 ++- 63 files changed, 690 insertions(+), 338 deletions(-) diff --git a/apps/server/src/agent-context/get-business-context.tool.ts b/apps/server/src/agent-context/get-business-context.tool.ts index b138eac3..ee751b92 100644 --- a/apps/server/src/agent-context/get-business-context.tool.ts +++ b/apps/server/src/agent-context/get-business-context.tool.ts @@ -39,10 +39,12 @@ export class GetBusinessContextTool implements ToolDef return { ok: true, value: { workflowKey: workflowKey.value } }; } - async execute(input: GetBusinessContextInput, context: AgentToolContext): Promise { - return this.service.getBusinessContext( - { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, - input.workflowKey, + execute(input: GetBusinessContextInput, context: AgentToolContext): Promise { + return Promise.resolve( + this.service.getBusinessContext( + { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, + input.workflowKey, + ), ); } } @@ -81,10 +83,12 @@ export class GetEntitySchemaTool implements ToolDef<{ entityKey: string }> { return { ok: true, value: { entityKey } }; } - async execute(input: { entityKey: string }, context: AgentToolContext): Promise { - return this.service.getEntitySchema( - { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, - input.entityKey, + execute(input: { entityKey: string }, context: AgentToolContext): Promise { + return Promise.resolve( + this.service.getEntitySchema( + { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, + input.entityKey, + ), ); } } diff --git a/apps/server/src/agent-context/get-pending-tasks.tool.ts b/apps/server/src/agent-context/get-pending-tasks.tool.ts index 20fb37a9..7c6d48dc 100644 --- a/apps/server/src/agent-context/get-pending-tasks.tool.ts +++ b/apps/server/src/agent-context/get-pending-tasks.tool.ts @@ -39,7 +39,7 @@ export class GetPendingTasksTool implements ToolDef { if (input.workflowKey === undefined) return { ok: true, value: {} }; if ( typeof input.workflowKey !== 'string' || - !(BUSINESS_WORKFLOW_KEYS as readonly string[]).includes(input.workflowKey) + !(BUSINESS_WORKFLOW_KEYS).includes(input.workflowKey) ) { return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` }; } diff --git a/apps/server/src/agent-context/pending-tasks.service.ts b/apps/server/src/agent-context/pending-tasks.service.ts index 4f310f8d..17a78ad8 100644 --- a/apps/server/src/agent-context/pending-tasks.service.ts +++ b/apps/server/src/agent-context/pending-tasks.service.ts @@ -166,8 +166,8 @@ export class PendingTasksService { continue; } const { sql, params } = definition.sql(scope); - const rows = await this.dataSource.query(sql, params); - const count = Number((rows as Array>)[0]?.cnt ?? 0); + const rows = await this.dataSource.query>>(sql, params); + const count = Number(rows[0]?.cnt ?? 0); tasks.push({ key: definition.key, label: definition.label, diff --git a/apps/server/src/agent-tools/tools/create-student.tool.ts b/apps/server/src/agent-tools/tools/create-student.tool.ts index ddf79f1d..6b48716d 100644 --- a/apps/server/src/agent-tools/tools/create-student.tool.ts +++ b/apps/server/src/agent-tools/tools/create-student.tool.ts @@ -28,6 +28,11 @@ const FORBIDDEN_INPUT_KEYS = new Set([ const PHONE_RE = /^1[3-9]\d{9}$/; +/** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */ +function stringify(value: unknown): string { + return String(value); +} + /** * Creates a student archive from form-confirmed data. * @@ -99,7 +104,7 @@ export class CreateStudentTool implements ToolDef { } if (input.gender !== undefined) { - if (!['male', 'female', '男', '女'].includes(String(input.gender))) { + if (!['male', 'female', '男', '女'].includes(stringify(input.gender))) { return { ok: false, error: 'gender 只能是 male/female/男/女' }; } result.gender = input.gender as CreateStudentInput['gender']; diff --git a/apps/server/src/agent-tools/tools/update-students.tool.ts b/apps/server/src/agent-tools/tools/update-students.tool.ts index 8e2a5afa..71ede412 100644 --- a/apps/server/src/agent-tools/tools/update-students.tool.ts +++ b/apps/server/src/agent-tools/tools/update-students.tool.ts @@ -151,8 +151,9 @@ export class UpdateStudentsTool implements ToolDef { const seenIds = new Set(); const updates: UpdateStudentInput[] = []; - for (let index = 0; index < input.updates.length; index += 1) { - const raw = input.updates[index]; + const updatesList = input.updates as unknown[]; + for (let index = 0; index < updatesList.length; index += 1) { + const raw = updatesList[index]; if (!isPlainRecord(raw)) { return { ok: false, error: `第 ${index + 1} 条更新格式无效` }; } diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index 50a5f940..5312c741 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -1,5 +1,4 @@ import { - BadRequestException, ConflictException, ForbiddenException, NotFoundException, diff --git a/apps/server/src/ai-chat/ai-chat.service.ts b/apps/server/src/ai-chat/ai-chat.service.ts index ec390e19..9e98c33a 100644 --- a/apps/server/src/ai-chat/ai-chat.service.ts +++ b/apps/server/src/ai-chat/ai-chat.service.ts @@ -17,7 +17,7 @@ import { AiReviewService } from './ai-review.service'; import { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { AiConversation, AiMessage, AiToolRun } from './entities'; +import { AiAttachment, AiConversation, AiMessage, AiToolRun } from './entities'; import type { GenerationInput, ModelContentPart, @@ -157,7 +157,7 @@ export class AiChatService extends AiChatServiceBase { buildUserContent( text: string, - attachments: any[], + attachments: AiAttachment[], supportsVision: boolean, ): Promise { return buildUserContent(this, text, attachments, supportsVision); diff --git a/apps/server/src/ai-chat/ai-chat.streaming.ts b/apps/server/src/ai-chat/ai-chat.streaming.ts index ef01afe9..331e8bbe 100644 --- a/apps/server/src/ai-chat/ai-chat.streaming.ts +++ b/apps/server/src/ai-chat/ai-chat.streaming.ts @@ -15,7 +15,7 @@ import { MAX_HISTORY_MESSAGES, SYSTEM_PROMPT, } from './ai-chat.types'; -import { AiMessage } from './entities'; +import { AiAttachment, AiMessage } from './entities'; import type { AuthenticatedUser } from '../authorization'; import { a2uiReviewSubmitInfo, @@ -315,7 +315,7 @@ export async function buildContext( export async function buildUserContent( context: AiChatServiceContext, text: string, - attachments: any[], + attachments: AiAttachment[], supportsVision: boolean, ): Promise { if (!attachments.length) return text; diff --git a/apps/server/src/ai-chat/ai-chat.submissions.ts b/apps/server/src/ai-chat/ai-chat.submissions.ts index ea8a9adb..bdae7178 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.ts @@ -1,8 +1,4 @@ -import type { - AiChatServiceContext, - AiSseEmitter, -} from './ai-chat.types'; -import type { AuthenticatedUser } from '../authorization'; +import type { AiChatServiceContext } from './ai-chat.types'; export async function resolveFormConversationId( context: AiChatServiceContext, diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts index d5e07b1e..e865a261 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts @@ -1,7 +1,7 @@ import { IMPORT_STEP_KEYS, + type ImportRunDetail, type ImportStageRequest, - type ImportStepKey, } from '../imports/imports.types'; import { permittedStepKeys } from '../imports/imports.access'; import { expandStageSheets } from '../imports/imports.mapping'; @@ -111,7 +111,7 @@ export const executeStartImportWizard = makeImportToolExecutor( async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => { const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs); const [attachment] = await context.attachmentService.requireReadyOwned(ac.userId, [ - attachmentId as number, + attachmentId, ]); if (!isExcelAttachment(attachment)) throw new Error('附件不是 Excel 文件,无法生成导入向导'); const stages = Array.isArray(parsedRecord.stages) @@ -195,7 +195,7 @@ export const executeStartImportWizard = makeImportToolExecutor( }); }); -export function compactImportWizard(detail: any): { +export function compactImportWizard(detail: ImportRunDetail): { runId: string; fileName: string; sheets: Array<{ @@ -209,13 +209,13 @@ export function compactImportWizard(detail: any): { return { runId: detail.id, fileName: detail.fileName, - sheets: detail.sheets.map((sheet: any) => ({ + sheets: detail.sheets.map((sheet) => ({ name: sheet.name, suggestedStepKey: sheet.suggestedStepKey, headers: sheet.headers, rowCount: sheet.rowCount, })), - steps: detail.steps.map((step: any) => ({ + steps: detail.steps.map((step) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets, diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index aea7f183..390a140f 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -2,6 +2,12 @@ import { AiReview } from './entities/ai-review.entity'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import { buildA2uiArtifact } from './ai-a2ui.artifact'; import { finishToolRun, startToolRun } from './ai-chat.tools'; + +/** Array.isArray 的类型守卫:把 unknown 收窄为 unknown[] 而非 any[]。 */ +function isUnknownArray(value: unknown): value is unknown[] { + return Array.isArray(value); +} + export async function executeRenderForm( context: AiChatServiceContext, messageId: number, @@ -229,7 +235,7 @@ export async function executeRenderChart( if (!assistant) throw new Error('assistant message missing'); const chart = context.chartService.createChart(parsedArgs); const existingCharts = assistant.metadata?.a2uiChart; - const charts = Array.isArray(existingCharts) + const charts = isUnknownArray(existingCharts) ? [...existingCharts] : existingCharts ? [existingCharts] diff --git a/apps/server/src/ai-chat/ai-chat.types.ts b/apps/server/src/ai-chat/ai-chat.types.ts index fff9ed6c..bb6f598c 100644 --- a/apps/server/src/ai-chat/ai-chat.types.ts +++ b/apps/server/src/ai-chat/ai-chat.types.ts @@ -14,6 +14,7 @@ import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { + AiAttachment, AiConversation, AiMessage, AiReview, @@ -150,7 +151,7 @@ export interface AiChatServiceContext { ): Promise; buildUserContent( text: string, - attachments: any[], + attachments: AiAttachment[], supportsVision: boolean, ): Promise; truncateText(value: string, max: number): string; diff --git a/apps/server/src/attendance-devices/attendance-devices.controller.ts b/apps/server/src/attendance-devices/attendance-devices.controller.ts index 6177e8ca..142e2346 100644 --- a/apps/server/src/attendance-devices/attendance-devices.controller.ts +++ b/apps/server/src/attendance-devices/attendance-devices.controller.ts @@ -6,6 +6,13 @@ import { extractRequestInfo } from '../common/request-utils'; import { AttendanceDevicesService } from './attendance-devices.service'; import { CreateAttendanceDeviceDto, UpdateAttendanceDeviceDto } from './dto/attendance-device.dto'; import { AttendanceDeviceStatus } from '../entities'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + headers?: Record; + connection?: { remoteAddress?: string }; +} @UseGuards(JwtAuthGuard) @Controller('attendance-devices') @@ -29,7 +36,7 @@ export class AttendanceDevicesController { @Post() @RequirePermission('classroom:edit') - async create(@Body() dto: CreateAttendanceDeviceDto, @Request() req: any) { + async create(@Body() dto: CreateAttendanceDeviceDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); await this.logService.log({ @@ -48,7 +55,7 @@ export class AttendanceDevicesController { @Put(':id') @RequirePermission('classroom:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceDeviceDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceDeviceDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(id, dto); await this.logService.log({ @@ -67,7 +74,7 @@ export class AttendanceDevicesController { @Delete(':id') @RequirePermission('classroom:edit') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(id); await this.logService.log({ diff --git a/apps/server/src/attendance/attendance-import.controller.ts b/apps/server/src/attendance/attendance-import.controller.ts index e3a93fa9..d8f1397e 100644 --- a/apps/server/src/attendance/attendance-import.controller.ts +++ b/apps/server/src/attendance/attendance-import.controller.ts @@ -6,7 +6,7 @@ import { AttendanceImportService } from './attendance-import.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AuthorizationService } from '../authorization'; import { logAudit } from '../common/with-audit-log'; -import { extractRequestInfo } from '../common/request-utils'; +import { extractRequestInfo, type RequestInfoSource } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { DingTalkImportDto } from './dto/dingtalk-import.dto'; import { QueryDingRawDto, MatchDingRecordDto } from './dto/attendance.dto'; @@ -35,7 +35,7 @@ export class AttendanceImportController extends AttendanceControllerBase { async matchDingRecord( @Param('id', ParseIntPipe) id: number, @Body() dto: MatchDingRecordDto, - @Request() req: any, + @Request() req: { user: RequestUser }, ) { const result = await this.service.matchDingRecord(id, dto); await logAudit(this.logService, req, { @@ -69,7 +69,10 @@ export class AttendanceImportController extends AttendanceControllerBase { */ @Post('attendance-records/import/dingtalk') @RequirePermission('attendance:create') - async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) { + async importFromDingTalk( + @Body() dto: DingTalkImportDto, + @Request() req: { user: RequestUser } & RequestInfoSource, + ) { const { ipAddress, userAgent } = extractRequestInfo(req); const canManageAll = this.canManageAllAttendance(req); let userIds: string[]; diff --git a/apps/server/src/attendance/attendance-records.controller.ts b/apps/server/src/attendance/attendance-records.controller.ts index fd97615e..c8c9d6ba 100644 --- a/apps/server/src/attendance/attendance-records.controller.ts +++ b/apps/server/src/attendance/attendance-records.controller.ts @@ -6,7 +6,7 @@ import { AttendanceImportService } from './attendance-import.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AuthorizationService } from '../authorization'; import { logAudit } from '../common/with-audit-log'; -import { extractRequestInfo } from '../common/request-utils'; +import { extractRequestInfo, type RequestInfoSource } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchCreateAttendanceDto, @@ -114,7 +114,10 @@ export class AttendanceRecordsController extends AttendanceControllerBase { // ── Batch create attendance records ── @Post('attendance-records/batch') @RequirePermission('attendance:create') - async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) { + async batchCreate( + @Body() dto: BatchCreateAttendanceDto, + @Request() req: { user: RequestUser } & RequestInfoSource, + ) { const { ipAddress, userAgent } = extractRequestInfo(req); const canManageAll = this.canManageAllAttendance(req); if (!canManageAll && dto.records.some((record) => record.classId == null)) { @@ -144,7 +147,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { // ── Generate attendance records from schedules (with optional date range) ── @Post('attendance-records/generate-from-schedules') @RequirePermission('attendance:create') - async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) { + async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: { user: RequestUser }) { await this.assertClassAccess(req, dto.classId); const result = await this.service.generateFromSchedules(dto); await logAudit(this.logService, req, { @@ -239,7 +242,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { @RequirePermission('attendance:edit', 'attendance:self-edit') async batchUpdateStatus( @Body() dto: BatchUpdateAttendanceStatusDto, - @Request() req: any, + @Request() req: { user: RequestUser }, ) { const failedIds: number[] = []; let updated = 0; @@ -269,7 +272,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { async update( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceRecordDto, - @Request() req: any, + @Request() req: { user: RequestUser }, ) { const existing = await this.service.findAttendanceRecord(id); if (existing.classId == null && !this.canManageAllAttendance(req)) { @@ -286,7 +289,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { // ── Delete a single attendance record ── @Delete('attendance-records/:id') @RequirePermission('attendance:edit', 'attendance:self-edit') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: { user: RequestUser }) { const existing = await this.service.findAttendanceRecord(id); if (existing.classId == null && !this.canManageAllAttendance(req)) { throw new ForbiddenException('无权删除未关联班级的考勤记录'); @@ -334,7 +337,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { async exportReport( @Query() query: AttendanceReportQueryDto, @Res() res: Response, - @Request() req: any, + @Request() req: { user: RequestUser }, ) { if (query.classId) await this.assertClassAccess(req, query.classId); const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req)); diff --git a/apps/server/src/auth/auth.controller.ts b/apps/server/src/auth/auth.controller.ts index c07dbaf9..b992c1c6 100644 --- a/apps/server/src/auth/auth.controller.ts +++ b/apps/server/src/auth/auth.controller.ts @@ -2,10 +2,15 @@ import { Controller, Post, Body, Get, Request, Req } from '@nestjs/common'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/auth.dto'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { extractRequestInfo, type RequestInfoSource } from '../common/request-utils'; import { Throttle } from '@nestjs/throttler'; import { Public } from './decorators/public.decorator'; import { Authenticated } from './decorators/authenticated.decorator'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest extends RequestInfoSource { + user: AuthenticatedUser; +} @Controller('auth') export class AuthController { @@ -17,7 +22,7 @@ export class AuthController { @Public() @Post('login') @Throttle({ default: { ttl: 60000, limit: 5 } }) - async login(@Body() dto: LoginDto, @Req() req: any) { + async login(@Body() dto: LoginDto, @Req() req: RequestInfoSource) { const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.authService.login(dto, ipAddress); @@ -31,12 +36,12 @@ export class AuthController { status: 'success', }); return result; - } catch (e: any) { + } catch (e: unknown) { await this.logService.log({ username: dto.username, module: '认证', action: '登录失败', - detail: e.message || '密码错误', + detail: e instanceof Error ? e.message || '密码错误' : '密码错误', ipAddress, userAgent, status: 'fail', @@ -47,7 +52,7 @@ export class AuthController { @Authenticated() @Get('profile') - getProfile(@Request() req: any) { + getProfile(@Request() req: AuthenticatedRequest) { return req.user; } } diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts index b3dd0a0c..d04ae1c1 100644 --- a/apps/server/src/auth/auth.service.ts +++ b/apps/server/src/auth/auth.service.ts @@ -93,7 +93,7 @@ export class AuthService { loginAttempts.set(key, attempt); } - async validateUser(payload: any) { + async validateUser(payload: { sub?: number }) { return this.userRepo.findOne({ where: { id: payload.sub } }); } } diff --git a/apps/server/src/bills/bills-export.service.ts b/apps/server/src/bills/bills-export.service.ts index 3f94ac3b..e3a38ba8 100644 --- a/apps/server/src/bills/bills-export.service.ts +++ b/apps/server/src/bills/bills-export.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call -- pdfkit/exceljs 无完整 TS 类型,属第三方库边界 */ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; diff --git a/apps/server/src/bills/bills.controller.ts b/apps/server/src/bills/bills.controller.ts index 43085e1e..746cdc73 100644 --- a/apps/server/src/bills/bills.controller.ts +++ b/apps/server/src/bills/bills.controller.ts @@ -27,6 +27,13 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import type { Response } from 'express'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} @UseGuards(JwtAuthGuard) @Controller('bills') @@ -42,7 +49,7 @@ export class BillsController { @Post('generate') @RequirePermission('bill:generate') - async generateBills(@Body() dto: GenerateBillsDto, @Request() req: any) { + async generateBills(@Body() dto: GenerateBillsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.generateBills(dto); await logAudit(this.logService, req, { module: '账单管理', action: '生成账单', detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count} 条`, @@ -91,7 +98,7 @@ export class BillsController { async updateStatus( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBillStatusDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.updateStatus(id, dto); await logAudit(this.logService, req, { @@ -114,7 +121,7 @@ export class BillsController { @Put('batch/status') @RequirePermission('bill:confirm') - async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) { + async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchUpdateStatus(body.ids, body.status); await logAudit(this.logService, req, { module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`, @@ -139,7 +146,7 @@ export class BillsController { @Post(':id/cancel') @RequirePermission('bill:delete') - async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: any) { + async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: AuthenticatedRequest) { const result = await this.service.cancel(id, dto, req.user?.id); await logAudit(this.logService, req, { module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason, @@ -149,7 +156,7 @@ export class BillsController { @Delete(':id') @RequirePermission('bill:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(id); await logAudit(this.logService, req, { module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill', @@ -159,7 +166,7 @@ export class BillsController { @Delete(':id/permanent') @RequirePermission('bill:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(id); await logAudit(this.logService, req, { module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复', @@ -169,7 +176,7 @@ export class BillsController { @Post('batch-permanent-delete') @RequirePermission('bill:purge') - async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurge(body.ids || []); await logAudit(this.logService, req, { module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -179,7 +186,7 @@ export class BillsController { @Post('batch/delete') @RequirePermission('bill:delete') - async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { + async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRemove(body.ids); await logAudit(this.logService, req, { module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`, @@ -190,12 +197,12 @@ export class BillsController { @Get('export/excel') @RequirePermission('bill:export-excel') async exportExcel( + @Req() req: AuthenticatedRequest, @Query('periodStart') periodStart?: string, @Query('periodEnd') periodEnd?: string, @Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number, @Query('status') status?: string, @Res() res?: Response, - @Req() req?: any, ) { await logAudit(this.logService, req, { module: '账单管理', action: '导出账单', detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`, @@ -213,7 +220,7 @@ export class BillsController { @Get('export/pdf/:id') @RequirePermission('bill:export-pdf') - async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) { + async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: AuthenticatedRequest) { await logAudit(this.logService, req, { module: '账单管理', action: '导出账单', targetId: id, targetType: 'bill', }); diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index 595be78e..feab198b 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -176,8 +176,10 @@ export class BillsService { } /** 查询时附加钱包余额和实际支付数据。 */ - private async attachDepositInfo(bills: Bill[]): Promise { - if (!bills?.length) return bills; + private async attachDepositInfo( + bills: Bill[], + ): Promise> { + if (!bills?.length) return bills as Array; const studentIds = Array.from(new Set(bills.map((bill) => bill.studentId))); const wallets = await this.dataSource .getRepository(StudentWallet) @@ -185,7 +187,7 @@ export class BillsService { .where('wallet.studentId IN (:...ids)', { ids: studentIds }) .getMany(); const balanceMap = new Map( - wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]), + wallets.map((wallet) => [wallet.studentId, Number(wallet.balance || 0)]), ); return bills.map((bill) => ({ ...bill, diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index 139cbb5f..29dfaf7e 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -115,7 +115,7 @@ export class ClassesController { @Post() @RequirePermission('class:create') - async create(@Body() dto: CreateClassDto, @Request() req: any) { + async create(@Body() dto: CreateClassDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto); await logAudit(this.logService, req, { module: '班级管理', action: '创建班级', targetId: result.id, targetType: 'class', detail: `班级${result.code} ${result.name}`, @@ -146,7 +146,7 @@ export class ClassesController { @Put(':id') @RequirePermission('class:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: AuthenticatedRequest) { const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto), @@ -156,7 +156,7 @@ export class ClassesController { @Delete(':id') @RequirePermission('class:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class', @@ -166,7 +166,7 @@ export class ClassesController { @Delete(':id/permanent') @RequirePermission('class:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复', @@ -226,7 +226,7 @@ export class ClassesController { @Post(':id/students') @RequirePermission('class:edit') - async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: any) { + async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.addStudents(+id, dto.studentIds); await logAudit(this.logService, req, { module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`, @@ -252,7 +252,7 @@ export class ClassesController { async removeStudent( @Param('id', ParseIntPipe) id: number, @Param('studentId', ParseIntPipe) studentId: number, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.removeStudent(+id, +studentId); await logAudit(this.logService, req, { @@ -270,7 +270,7 @@ export class ClassesController { @Post(':id/teachers') @RequirePermission('class:edit') - async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: any) { + async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: AuthenticatedRequest) { const result = await this.service.addTeacher(+id, dto); await logAudit(this.logService, req, { module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`, @@ -293,7 +293,7 @@ export class ClassesController { async removeTeacherAssignment( @Param('id', ParseIntPipe) id: number, @Param('assignmentId', ParseIntPipe) assignmentId: number, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.removeTeacherAssignment(+id, +assignmentId); await logAudit(this.logService, req, { @@ -307,7 +307,7 @@ export class ClassesController { async removeTeacher( @Param('id', ParseIntPipe) id: number, @Param('userId', ParseIntPipe) userId: number, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.removeTeacher(+id, +userId); await logAudit(this.logService, req, { diff --git a/apps/server/src/classes/dto/class.dto.ts b/apps/server/src/classes/dto/class.dto.ts index 3d1e837a..df09081f 100644 --- a/apps/server/src/classes/dto/class.dto.ts +++ b/apps/server/src/classes/dto/class.dto.ts @@ -159,7 +159,7 @@ export class QueryClassDto { if (typeof value === 'boolean') return value; if (value === 'true' || value === '1') return true; if (value === 'false' || value === '0') return false; - return value; + return value as boolean; }) @IsBoolean() isArchived?: boolean; diff --git a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts index d64be008..4c8b6ba0 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts @@ -25,6 +25,12 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +interface AuthenticatedRequest { + user?: { id: number; username: string }; + headers?: Record; + connection?: { remoteAddress?: string }; +} + @UseGuards(JwtAuthGuard) @Controller('classroom-rentals') export class ClassroomRentalsController { @@ -102,7 +108,7 @@ export class ClassroomRentalsController { @Post() @RequirePermission('rental:create') - async create(@Body() dto: CreateRentalDto, @Request() req: any) { + async create(@Body() dto: CreateRentalDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto, req.user?.id); await logAudit(this.logService, req, { module: '教室租赁', action: '新增租赁', targetId: result.id, targetType: 'classroom-rental', detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`, @@ -112,7 +118,7 @@ export class ClassroomRentalsController { @Put(':id') @RequirePermission('rental:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRentalDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRentalDto, @Request() req: AuthenticatedRequest) { const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental', detail: JSON.stringify(dto), @@ -122,7 +128,7 @@ export class ClassroomRentalsController { @Put(':id/cancel') @RequirePermission('rental:edit') - async cancel(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async cancel(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.cancel(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '取消租赁', targetId: +id, targetType: 'classroom-rental', @@ -132,7 +138,7 @@ export class ClassroomRentalsController { @Put(':id/end') @RequirePermission('rental:edit') - async end(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async end(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.end(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '结束租赁', targetId: +id, targetType: 'classroom-rental', @@ -142,7 +148,7 @@ export class ClassroomRentalsController { @Delete(':id') @RequirePermission('rental:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '归档租赁', targetId: +id, targetType: 'classroom-rental', @@ -152,7 +158,7 @@ export class ClassroomRentalsController { @Delete(':id/permanent') @RequirePermission('rental:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '永久删除租赁订单', targetId: +id, targetType: 'classroom-rental', detail: '物理删除,不可恢复', @@ -177,7 +183,7 @@ export class ClassroomRentalsController { async uploadContract( @Param('id', ParseIntPipe) id: number, @UploadedFile() file: Express.Multer.File, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { if (!file) throw new BadRequestException('请上传合同文件'); const result = await this.service.attachContract(+id, file); @@ -202,7 +208,7 @@ export class ClassroomRentalsController { @Delete(':id/contract') @RequirePermission('rental:edit') - async deleteContract(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async deleteContract(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.removeContract(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '移除合同', targetId: +id, targetType: 'classroom-rental', diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index 2e48720a..8cd9f4ca 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -19,6 +19,19 @@ import * as path from 'path'; import * as fs from 'fs'; import { randomBytes } from 'crypto'; +/** getRawMany 原始行:驱动可能返回 string/number,date 列可能是 string 或 Date */ +interface AgentRentalRawRow { + id: string | number; + classroomName: string | null; + lesseeOrganizationName: string | null; + startDate: string | Date; + endDate: string | Date; + dailyRate: string | number | null; + totalAmount: string | number | null; + status: string; + contractName: string | null; +} + // 预设色板(与 organizations.service 保持一致,作为颜色兜底) function rentalConflictError( message: string, @@ -142,7 +155,7 @@ export class ClassroomRentalsService { const rows = await qb .orderBy('r.startDate', 'DESC') .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) - .getRawMany>(); + .getRawMany(); return rows.map((row) => ({ id: Number(row.id), classroomName: row.classroomName == null ? '' : String(row.classroomName), diff --git a/apps/server/src/classrooms/classrooms.controller.ts b/apps/server/src/classrooms/classrooms.controller.ts index 1cd7e6d1..347b8b74 100644 --- a/apps/server/src/classrooms/classrooms.controller.ts +++ b/apps/server/src/classrooms/classrooms.controller.ts @@ -25,6 +25,21 @@ import { RequirePermission } from '../auth/decorators/permission.decorator'; import * as ExcelJS from 'exceljs'; import { CLASSROOM_TEMPLATE_COLUMNS } from './classroom-template'; +interface AuthenticatedRequest { + user?: { id: number; username: string }; + headers?: Record; + connection?: { remoteAddress?: string }; +} + +/** + * exceljs 单元格标量文本。保持原 `String(value || '')` 语义。 + * CellValue 联合类型含富文本/超链接对象,实际导入数据均为标量,故在辅助函数内局部豁免。 + */ +function cellValueText(value: unknown): string { + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- exceljs CellValue 联合类型含富文本对象,实际导入数据为标量 + return String(value || ''); +} + @UseGuards(JwtAuthGuard) @Controller('classrooms') export class ClassroomsController { @@ -104,7 +119,7 @@ export class ClassroomsController { @Post() @RequirePermission('classroom:create') - async create(@Body() dto: CreateClassroomDto, @Request() req: any) { + async create(@Body() dto: CreateClassroomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto); await logAudit(this.logService, req, { module: '教室', action: '新增教室', targetId: result.id, targetType: 'classroom', detail: dto.name, @@ -114,7 +129,7 @@ export class ClassroomsController { @Put(':id') @RequirePermission('classroom:edit') - async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: any) { + async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '教室', action: '编辑教室', targetId: +id, targetType: 'classroom', detail: JSON.stringify(dto), @@ -124,7 +139,7 @@ export class ClassroomsController { @Delete(':id') @RequirePermission('classroom:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '教室', action: '归档教室', targetId: +id, targetType: 'classroom', @@ -134,7 +149,7 @@ export class ClassroomsController { @Delete(':id/permanent') @RequirePermission('classroom:purge') - async purge(@Param('id') id: string, @Request() req: any) { + async purge(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '教室', action: '永久删除教室', targetId: +id, targetType: 'classroom', detail: '物理删除,不可恢复', @@ -144,7 +159,7 @@ export class ClassroomsController { @Put(':id/restore') @RequirePermission('classroom:edit') - async restore(@Param('id') id: string, @Request() req: any) { + async restore(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.restore(+id); await logAudit(this.logService, req, { module: '教室', action: '恢复教室', targetId: +id, targetType: 'classroom', @@ -155,19 +170,25 @@ export class ClassroomsController { @Post('import') @RequirePermission('classroom:create') @UseInterceptors(FileInterceptor('file')) - async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; - const rows: any[] = []; + const rows: { + name: string; + building?: string; + floor?: number; + roomType?: string; + capacity?: number; + }[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; rows.push({ - name: String(row.getCell(1).value || ''), - building: String(row.getCell(2).value || '') || undefined, + name: cellValueText(row.getCell(1).value), + building: cellValueText(row.getCell(2).value) || undefined, floor: Number(row.getCell(3).value) || undefined, - roomType: String(row.getCell(4).value || '') || undefined, + roomType: cellValueText(row.getCell(4).value) || undefined, capacity: Number(row.getCell(5).value) || undefined, }); }); diff --git a/apps/server/src/classrooms/classrooms.service.ts b/apps/server/src/classrooms/classrooms.service.ts index 92339bd7..ddbb6077 100644 --- a/apps/server/src/classrooms/classrooms.service.ts +++ b/apps/server/src/classrooms/classrooms.service.ts @@ -7,6 +7,25 @@ import { ClassSchedule } from '../entities/class-schedule.entity'; import { AttendanceDevice } from '../entities/attendance-device.entity'; import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto'; +/** getRawMany 原始行:驱动可能返回 string/number,date 列可能是 string 或 Date */ +interface ScheduleUsageRawRow { + classroomId: string | number; + startTime: string; + endTime: string; + startDate: string | Date; + endDate: string | Date; + weekDay: string | number; + subject: string | null; + className: string | null; +} + +interface RentalUsageRawRow { + classroomId: string | number; + startDate: string | Date; + endDate: string | Date; + tenantName: string | null; +} + @Injectable() export class ClassroomsService { constructor( @@ -255,7 +274,7 @@ export class ClassroomsService { .andWhere('s.status = :active', { active: 'active' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.endDate >= :today', { today: todayStr }) - .getRawMany(); + .getRawMany(); for (const schedule of schedules) { const classroomId = Number(schedule.classroomId); @@ -291,7 +310,7 @@ export class ClassroomsService { .where('r.classroomId IN (:...ids)', { ids: classroomIds }) .andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }) .andWhere('r.endDate >= :today', { today: todayStr }) - .getRawMany(); + .getRawMany(); for (const rental of rentals) { const classroomId = Number(rental.classroomId); diff --git a/apps/server/src/common/request-utils.ts b/apps/server/src/common/request-utils.ts index 4132819c..5ba570c4 100644 --- a/apps/server/src/common/request-utils.ts +++ b/apps/server/src/common/request-utils.ts @@ -1,13 +1,19 @@ +/** 从请求对象提取 IP / UA 所需的最小结构。 */ +export interface RequestInfoSource { + headers?: Record; + connection?: { remoteAddress?: string }; +} + /** * 从请求对象中提取客户端 IP 和 UserAgent */ -export function extractRequestInfo(req: any): { ipAddress: string; userAgent: string } { +export function extractRequestInfo(req: RequestInfoSource): { ipAddress: string; userAgent: string } { const forwarded = req.headers?.['x-forwarded-for'] || req.headers?.['x-real-ip'] || req.connection?.remoteAddress || ''; const ipAddress = String(forwarded).split(',')[0].trim() || 'unknown'; - const userAgent = (req.headers?.['user-agent'] || '').substring(0, 500); + const userAgent = String(req.headers?.['user-agent'] || '').substring(0, 500); return { ipAddress, userAgent }; } diff --git a/apps/server/src/dashboard/dashboard-queries.service.ts b/apps/server/src/dashboard/dashboard-queries.service.ts index be279ecc..5b4e06f5 100644 --- a/apps/server/src/dashboard/dashboard-queries.service.ts +++ b/apps/server/src/dashboard/dashboard-queries.service.ts @@ -57,12 +57,12 @@ async getAttendanceTrend( .groupBy('a.attendanceDate') .addGroupBy('a.status') .orderBy('a.attendanceDate', 'ASC') - .getRawMany(); + .getRawMany<{ date: string; status: string; count: string | number }>(); const dayMap = new Map(); for (const row of rows) { const d = dayMap.get(row.date) || { total: 0, present: 0 }; - const cnt = parseInt(row.count, 10); + const cnt = parseInt(String(row.count), 10); d.total += cnt; if (row.status === 'present') d.present += cnt; dayMap.set(row.date, d); @@ -92,11 +92,11 @@ async getIncomeTrend( .where('b.status = :paid', { paid: 'paid' }) .andWhere('b.periodStart >= :start', { start: `${m}-01` }) .andWhere('b.periodStart < :end', { end: nextMonth(m) }) - .getRawOne(); + .getRawOne<{ total: string | number | null }>(); results.push({ month: m, - amount: parseFloat(row?.total || '0'), + amount: parseFloat(String(row?.total || '0')), }); } @@ -216,15 +216,21 @@ async getClassAttendanceRanking( .addSelect('COUNT(*)', 'count'); applyClassScope(qb, 'a', accessibleClassIds); qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status'); - const raw = await qb.getRawMany(); + const raw = await qb.getRawMany<{ + classId: string | number | null; + className: string | null; + status: string; + count: string | number; + }>(); const classMap = new Map(); for (const r of raw) { if (!r.classId) continue; - if (!classMap.has(Number(r.classId))) - classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 }); - const entry = classMap.get(Number(r.classId))!; - const n = parseInt(r.count, 10); + const classId = Number(r.classId); + if (!classMap.has(classId)) + classMap.set(classId, { className: r.className ?? '', present: 0, total: 0 }); + const entry = classMap.get(classId)!; + const n = parseInt(String(r.count), 10); entry.total += n; if (r.status === 'present') entry.present += n; } diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index b7309aa2..d940dfe2 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -100,7 +100,7 @@ export class DashboardService { .createQueryBuilder('r') .select('SUM(r.capacity)', 'total') .where('r.status != :archived', { archived: 'archived' }); - const totalCapacity = await capQb.getRawOne(); + const totalCapacity = await capQb.getRawOne<{ total: string | number | null }>(); // MySQL 的 SUM() 聚合默认以字符串返回,需显式转成 number const cap = Number(totalCapacity?.total ?? 0) || 0; const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0; @@ -111,7 +111,11 @@ export class DashboardService { .addSelect('COUNT(*)', 'count') .addSelect('SUM(b.totalAmount)', 'total') .groupBy('b.status'); - const billStats = await billStatsQb.getRawMany(); + const billStats = await billStatsQb.getRawMany<{ + status: string; + count: string | number; + total: string | number; + }>(); // New fields const classroomCount = await this.classroomRepo.count({ where: {} }); @@ -122,8 +126,8 @@ export class DashboardService { .where('s.status = :active', { active: 'active' }) .andWhere('s.startDate <= :today', { today: todayStr }) .andWhere('s.endDate >= :today', { today: todayStr }); - const occResult = await occQb.getRawOne(); - const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10); + const occResult = await occQb.getRawOne<{ cnt: string | number | null }>(); + const occupiedClassrooms = parseInt(String(occResult?.cnt || '0'), 10); const classroomOccupancyRate = classroomCount > 0 ? ((occupiedClassrooms / classroomCount) * 100).toFixed(1) : 0; @@ -134,18 +138,18 @@ export class DashboardService { .where('a.attendanceDate = :today', { today: todayStr }); this.applyClassScope(attTodayQb, 'a', accessibleClassIds); attTodayQb.groupBy('a.status'); - const attTodayStats = await attTodayQb.getRawMany(); + const attTodayStats = await attTodayQb.getRawMany<{ status: string; count: string | number }>(); const todayPresent = attTodayStats .filter((r) => r.status === 'present') - .reduce((sum, r) => sum + parseInt(r.count, 10), 0); + .reduce((sum, r) => sum + parseInt(String(r.count), 10), 0); const incomeQb = this.billRepo .createQueryBuilder('b') .select('SUM(b.totalAmount)', 'total') .where('b.status = :paid', { paid: 'paid' }) .andWhere('b.periodStart >= :start', { start: `${currentMonth}-01` }) .andWhere('b.periodStart < :end', { end: this.nextMonth(currentMonth) }); - const incomeResult = await incomeQb.getRawOne(); - const monthlyIncome = parseFloat(incomeResult?.total || '0'); + const incomeResult = await incomeQb.getRawOne<{ total: string | number | null }>(); + const monthlyIncome = parseFloat(String(incomeResult?.total || '0')); const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds); const incomeTrend = await this.getIncomeTrend(currentMonth); @@ -157,15 +161,15 @@ export class DashboardService { const teacherResult = await this.classTeacherRepo .createQueryBuilder('ct') .select('COUNT(DISTINCT ct.userId)', 'cnt') - .getRawOne(); - const teacherCount = parseInt(teacherResult?.cnt || '0', 10); + .getRawOne<{ cnt: string | number | null }>(); + const teacherCount = parseInt(String(teacherResult?.cnt || '0'), 10); const pendingQb = this.depositRepo .createQueryBuilder('d') .select('SUM(d.amount)', 'total') .where('d.status = :paid', { paid: 'paid' }); - const pendingResult = await pendingQb.getRawOne(); - const pendingDeposits = parseFloat(pendingResult?.total || '0'); + const pendingResult = await pendingQb.getRawOne<{ total: string | number | null }>(); + const pendingDeposits = parseFloat(String(pendingResult?.total || '0')); const activeRentals = await this.rentalRepo.count({ where: { status: 'active' as const, endDate: MoreThanOrEqual(todayStr) }, @@ -177,11 +181,13 @@ export class DashboardService { .select('r.building', 'building') .addSelect('COUNT(*)', 'count') .where('o.checkOutDate IS NULL'); - const occupancyByBuilding = await occByBldQb.groupBy('r.building').getRawMany(); + const occupancyByBuilding = await occByBldQb + .groupBy('r.building') + .getRawMany<{ building: string | null; count: string | number }>(); const attendanceByStatus = attTodayStats.reduce( (acc, r) => { - acc[r.status] = parseInt(r.count, 10); + acc[r.status] = parseInt(String(r.count), 10); return acc; }, {} as Record, @@ -193,7 +199,9 @@ export class DashboardService { .addSelect('SUM(e.amount)', 'total') .where('e.periodStart >= :start', { start: `${currentMonth}-01` }) .andWhere('e.periodEnd <= :end', { end: this.nextMonth(currentMonth) }); - const expenseByType = await expByTypeQb.groupBy('e.expenseType').getRawMany(); + const expenseByType = await expByTypeQb + .groupBy('e.expenseType') + .getRawMany<{ type: string; total: string | number }>(); return { totalRooms, @@ -288,7 +296,7 @@ export class DashboardService { .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }) .groupBy('s.classroomId'); - const schedules = await schedQb.getRawMany(); + const schedules = await schedQb.getRawMany<{ classroomId: number; weekDays: string | number }>(); const rentalQb = this.rentalRepo .createQueryBuilder('r') .select('r.classroomId', 'classroomId') @@ -296,11 +304,11 @@ export class DashboardService { .where('r.status = :active', { active: 'active' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) .groupBy('r.classroomId'); - const rentals = await rentalQb.getRawMany(); + const rentals = await rentalQb.getRawMany<{ classroomId: number; rentalCount: string | number }>(); const sMap: Record = {}; const rMap: Record = {}; - for (const s of schedules) sMap[s.classroomId] = parseInt(s.weekDays, 10); - for (const r of rentals) rMap[r.classroomId] = parseInt(r.rentalCount, 10); + for (const s of schedules) sMap[s.classroomId] = parseInt(String(s.weekDays), 10); + for (const r of rentals) rMap[r.classroomId] = parseInt(String(r.rentalCount), 10); return classrooms.map((c) => ({ name: c.name, building: c.building || '', @@ -344,7 +352,7 @@ export class DashboardService { .where('s.status = :active', { active: 'active' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }); - const schedResult = await schedQb.getRawOne(); + const schedResult = await schedQb.getRawOne<{ cnt: string | number | null }>(); // Count classrooms with active rentals today const rentalQb = this.rentalRepo @@ -352,7 +360,7 @@ export class DashboardService { .select('COUNT(DISTINCT r.classroomId)', 'cnt') .where('r.status = :active', { active: 'active' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }); - const rentalResult = await rentalQb.getRawOne(); + const rentalResult = await rentalQb.getRawOne<{ cnt: string | number | null }>(); // Combine: use Set merge of both const combinedQb = this.scheduleRepo @@ -362,7 +370,7 @@ export class DashboardService { .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }) .groupBy('s.classroomId'); - const schedIds = await combinedQb.getRawMany(); + const schedIds = await combinedQb.getRawMany<{ classroomId: number }>(); const combinedRentalQb = this.rentalRepo .createQueryBuilder('r') @@ -370,15 +378,15 @@ export class DashboardService { .where('r.status = :active', { active: 'active' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) .groupBy('r.classroomId'); - const rentalIds = await combinedRentalQb.getRawMany(); + const rentalIds = await combinedRentalQb.getRawMany<{ classroomId: number }>(); const allInUseIds = new Set([ ...schedIds.map((s) => s.classroomId), ...rentalIds.map((r) => r.classroomId), ]); - const scheduleCount = parseInt(schedResult?.cnt || '0', 10); - const rentalCount = parseInt(rentalResult?.cnt || '0', 10); + const scheduleCount = parseInt(String(schedResult?.cnt || '0'), 10); + const rentalCount = parseInt(String(rentalResult?.cnt || '0'), 10); const inUseCount = allInUseIds.size; const utilizationRate = totalClassrooms > 0 ? ((inUseCount / totalClassrooms) * 100).toFixed(1) : '0'; diff --git a/apps/server/src/database/database-migrations.attendance.ts b/apps/server/src/database/database-migrations.attendance.ts index 61f918f0..ad725315 100644 --- a/apps/server/src/database/database-migrations.attendance.ts +++ b/apps/server/src/database/database-migrations.attendance.ts @@ -138,7 +138,7 @@ export async function migrateMySQLAttendanceFKs( // Drop any existing FK constraint on schedule_id or class_id const fkColumns = ['schedule_id', 'class_id']; for (const col of fkColumns) { - const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query( + const fkRows = (await runner.query( ` SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE @@ -148,7 +148,7 @@ export async function migrateMySQLAttendanceFKs( AND REFERENCED_TABLE_NAME IS NOT NULL `, [col], - ); + )) as Array<{ CONSTRAINT_NAME: string }>; for (const row of fkRows) { try { @@ -168,7 +168,7 @@ export async function migrateMySQLAttendanceFKs( ]; for (const c of constraints) { // Only skip if RESTRICT constraint is already confirmed via information_schema - const existing: Array<{ DELETE_RULE: string }> = await runner.query( + const existing = (await runner.query( ` SELECT DELETE_RULE FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS @@ -177,7 +177,7 @@ export async function migrateMySQLAttendanceFKs( AND CONSTRAINT_NAME = ? `, [c.name], - ); + )) as Array<{ DELETE_RULE: string }>; if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') { logger.log(`考勤场次删除保护约束已存在: ${c.name}`); diff --git a/apps/server/src/database/database-migrations.backfill.ts b/apps/server/src/database/database-migrations.backfill.ts index 2444b36d..744d193f 100644 --- a/apps/server/src/database/database-migrations.backfill.ts +++ b/apps/server/src/database/database-migrations.backfill.ts @@ -3,6 +3,16 @@ import { DataSource } from 'typeorm'; import { uuidV7 } from '../common/uuid-v7'; import { withQueryRunner } from './database-migrations.runner'; +/** 迁移脚本中用到的 organizations 表最小行结构。 */ +interface OrganizationRow { + id: number; +} + +/** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */ +function stringify(value: unknown): string { + return String(value); +} + export async function backfillOrganizations( dataSource: DataSource, ): Promise { @@ -17,8 +27,8 @@ export async function backfillOrganizations( const tableNames = new Set(tables.map((table) => table.name)); if (!tableNames.has('organizations')) return; - const organizationRows = () => - runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1'); + const organizationRows = (): Promise => + runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1') as Promise; let host = (await organizationRows())[0]; if (!host) { await runner.query( @@ -39,14 +49,17 @@ export async function backfillOrganizations( if (!host) return; if (tableNames.has('tenants')) { - const legacyTenants: Array> = - await runner.query('SELECT * FROM tenants'); + const legacyTenants = (await runner.query('SELECT * FROM tenants')) as Array< + Record + >; for (const legacy of legacyTenants) { - const name = String(legacy.name || '').trim(); + const name = stringify(legacy.name || '').trim(); if (!name) continue; - let external = ( - await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name]) - )[0]; + const externalRows = (await runner.query( + 'SELECT * FROM organizations WHERE name = ? LIMIT 1', + [name], + )) as OrganizationRow[]; + let external = externalRows[0]; if (!external) { await runner.query( `INSERT INTO organizations (public_id, code, name, is_host, contact_name, phone, color, notes, status, created_at, updated_at) @@ -64,7 +77,7 @@ export async function backfillOrganizations( ], ); external = ( - await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name]) + (await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])) as OrganizationRow[] )[0]; } if (!external) continue; @@ -158,7 +171,11 @@ export async function normalizeClassDates( .map((column) => `${column} = ${normalizedDate(column)}`) .join(',\n '); const predicates = columns.map((column) => needsNormalization(column)).join('\n OR '); - const result = await dataSource.transaction((manager) => + interface UpdateResultLike { + changes?: number; + affectedRows?: number; + } + const result = await dataSource.transaction((manager) => manager.query(` UPDATE classes SET diff --git a/apps/server/src/deposits/deposits.controller.ts b/apps/server/src/deposits/deposits.controller.ts index 060300e1..0e4678ef 100644 --- a/apps/server/src/deposits/deposits.controller.ts +++ b/apps/server/src/deposits/deposits.controller.ts @@ -27,6 +27,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} @UseGuards(JwtAuthGuard) @Controller('deposits') @@ -77,7 +84,7 @@ export class DepositsController { @Post() @RequirePermission('deposit:create') - async create(@Body() dto: CreateDepositDto, @Request() req: any) { + async create(@Body() dto: CreateDepositDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto, req.user?.id); await logAudit(this.logService, req, { module: '押金管理', action: '收取押金', targetId: result.id, targetType: 'deposit', detail: `学生${dto.studentId} ¥${dto.amount}`, @@ -89,7 +96,7 @@ export class DepositsController { @Post('batch') @RequirePermission('deposit:create') - async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: any) { + async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchCreate(dto, req.user?.id); await logAudit(this.logService, req, { module: '押金管理', action: '批量收取押金', targetType: 'deposit', detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`, @@ -140,7 +147,7 @@ export class DepositsController { @Put(':id/refund') @RequirePermission('deposit:refund') - async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) { + async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: AuthenticatedRequest) { const result = await this.service.refund(id, dto, req.user?.id); await logAudit(this.logService, req, { module: '押金管理', action: '退还押金', targetId: id, targetType: 'deposit', detail: `退还全部可用押金 ¥${result.refundAmount}`, @@ -167,7 +174,7 @@ export class DepositsController { @Delete(':id') @RequirePermission('deposit:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(id); await logAudit(this.logService, req, { module: '押金管理', action: '归档押金记录', targetId: id, targetType: 'deposit', @@ -177,7 +184,7 @@ export class DepositsController { @Delete(':id/permanent') @RequirePermission('deposit:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(id); await logAudit(this.logService, req, { module: '押金管理', action: '永久删除押金', targetId: id, targetType: 'deposit', detail: '物理删除,不可恢复', diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index 8dc741ae..b4d4bcee 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -10,6 +10,9 @@ import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); +/** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */ +type RawScalarRow = Record; + const capacityRoomTypeText: Record = { 1: '单人间', 2: '二人间', @@ -99,7 +102,7 @@ export class DepositsService { } } - const rows = await qb.getRawMany(); + const rows = await qb.getRawMany(); return rows.map((row) => ({ studentId: Number(row.studentId), studentName: row.studentName, @@ -107,9 +110,12 @@ export class DepositsService { roomId: Number(row.roomId), roomNumber: row.roomNumber, building: row.building ?? null, - roomType: normalizeRoomType(row.roomType, row.capacity), + roomType: normalizeRoomType( + row.roomType == null ? null : String(row.roomType), + row.capacity == null ? null : Number(row.capacity), + ), capacity: Number(row.capacity), - depositAmount: money(row.depositAmount), + depositAmount: money(row.depositAmount == null ? null : Number(row.depositAmount)), })); } @@ -198,7 +204,7 @@ export class DepositsService { const rows = await qb .orderBy('d.createdAt', 'DESC') .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) - .getRawMany>(); + .getRawMany(); return rows.map((row) => ({ id: Number(row.id), studentName: row.studentName == null ? '' : String(row.studentName), diff --git a/apps/server/src/entities/jinshuju-match-rule.entity.ts b/apps/server/src/entities/jinshuju-match-rule.entity.ts index 5e01cb3d..59c5d258 100644 --- a/apps/server/src/entities/jinshuju-match-rule.entity.ts +++ b/apps/server/src/entities/jinshuju-match-rule.entity.ts @@ -22,7 +22,7 @@ const mappingTransformer = { }, from(value: string | null): JinshujuFieldMapping { if (!value) return {}; - return JSON.parse(value); + return JSON.parse(value) as JinshujuFieldMapping; }, }; diff --git a/apps/server/src/exams/dto/exam.dto.ts b/apps/server/src/exams/dto/exam.dto.ts index 93dc52ac..e280865b 100644 --- a/apps/server/src/exams/dto/exam.dto.ts +++ b/apps/server/src/exams/dto/exam.dto.ts @@ -29,7 +29,7 @@ export class QueryExamDto { if (typeof value === 'boolean') return value; if (value === 'true' || value === '1') return true; if (value === 'false' || value === '0') return false; - return value; + return value as boolean; }) @IsBoolean() isArchived?: boolean; diff --git a/apps/server/src/expense-types/expense-types.controller.ts b/apps/server/src/expense-types/expense-types.controller.ts index 3813d151..724658a1 100644 --- a/apps/server/src/expense-types/expense-types.controller.ts +++ b/apps/server/src/expense-types/expense-types.controller.ts @@ -5,6 +5,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} @UseGuards(JwtAuthGuard) @Controller('expense-types') @@ -28,7 +35,7 @@ export class ExpenseTypesController { @Post() @RequirePermission('expense:create') - async create(@Body() dto: CreateExpenseTypeDto, @Request() req: any) { + async create(@Body() dto: CreateExpenseTypeDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); await this.logService.log({ @@ -47,7 +54,7 @@ export class ExpenseTypesController { @Put(':id') @RequirePermission('expense:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseTypeDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseTypeDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); await this.logService.log({ @@ -66,7 +73,7 @@ export class ExpenseTypesController { @Delete(':id') @RequirePermission('expense:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); await this.service.remove(+id); await this.logService.log({ diff --git a/apps/server/src/expenses/expense-operations.service.ts b/apps/server/src/expenses/expense-operations.service.ts index 2f6e2ad1..fac81fb7 100644 --- a/apps/server/src/expenses/expense-operations.service.ts +++ b/apps/server/src/expenses/expense-operations.service.ts @@ -290,8 +290,10 @@ export class ExpenseOperationsService { skipped++; errors.push(`第${rowNum}行: ${row.roomNumber} 无有效金额`); } - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`); + } catch (e: unknown) { + errors.push( + `第${rowNum}行: ${row.roomNumber} 导入失败 - ${e instanceof Error ? e.message : '未知错误'}`, + ); skipped++; } } @@ -404,8 +406,10 @@ export class ExpenseOperationsService { // 校验金额 try { this.assertPositiveAmount(row.amount); - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.studentName} ${e.message}`); + } catch (e: unknown) { + errors.push( + `第${rowNum}行: ${row.studentName} ${e instanceof Error ? e.message : '未知错误'}`, + ); skipped++; continue; } @@ -422,8 +426,10 @@ export class ExpenseOperationsService { ); imported++; - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.studentName} 导入失败 - ${e.message}`); + } catch (e: unknown) { + errors.push( + `第${rowNum}行: ${row.studentName} 导入失败 - ${e instanceof Error ? e.message : '未知错误'}`, + ); skipped++; } } diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 76aa6adb..170992c0 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -34,23 +34,60 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; +import type { AuthenticatedUser } from '../authorization'; +import { PersonalExpense } from '../entities/personal-expense.entity'; import * as ExcelJS from 'exceljs'; +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} + +/** ExcelJS 单元格解析出的标量值 */ +type CellScalar = string | number | boolean | Date | null | undefined; + +interface UtilityImportRow { + periodStr: string; + roomNumber: string; + electricityAmount: number; + electricityFee: number; + waterAmount: number; + waterFee: number; + totalFee: number; +} + +interface PersonalImportRow { + studentName: string; + expenseType: string; + amount: number; + expenseDate: string; + description?: string; +} + /** 提取 ExcelJS 单元格的真实值,兼容公式、富文本、日期、超链接等情况 */ -function readCell(cell: ExcelJS.Cell): any { - let v: any = cell?.value; +function readCell(cell: ExcelJS.Cell | undefined): CellScalar { + let v: unknown = cell?.value; if (v == null) return ''; - if (typeof v === 'object') { + if (typeof v === 'object' && !(v instanceof Date)) { + const record = v as Record; // 公式单元格:{ formula, result } - if ('result' in v) v = v.result; + if ('result' in record) v = record.result; // 富文本:{ richText: [...] } - else if ('richText' in v && Array.isArray(v.richText)) { - return v.richText.map((r: any) => r.text || '').join(''); + else if ('richText' in record && Array.isArray(record.richText)) { + return record.richText + .map((r) => + r !== null && typeof r === 'object' && 'text' in r + ? (r as { text?: string }).text || '' + : '', + ) + .join(''); } // 超链接:{ text, hyperlink } - else if ('text' in v) v = v.text; + else if ('text' in record) v = record.text; // 错误值:{ error: '#DIV/0!' } - else if ('error' in v) return ''; + else if ('error' in record) return ''; + else return ''; } if (v instanceof Date) { const y = v.getFullYear(); @@ -58,7 +95,8 @@ function readCell(cell: ExcelJS.Cell): any { const d = String(v.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } - return v; + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v; + return v == null ? v : ''; } function readCellNum(cell: ExcelJS.Cell): number { @@ -95,7 +133,7 @@ export class ExpensesController { @Post('student-utility') @RequirePermission('expense:create') - async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) { + async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: AuthenticatedRequest) { const result = await this.service.createStudentUtilityBill(dto, req.user?.id); await logAudit(this.logService, req, { module: '费用管理', action: '录入学生水电费并出账', targetId: result.bill.id, targetType: 'bill', detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`, @@ -105,7 +143,7 @@ export class ExpensesController { @Post('room') @RequirePermission('expense:create') - async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) { + async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: AuthenticatedRequest) { const result = await this.service.createRoomExpense(dto, req.user?.id); await logAudit(this.logService, req, { module: '费用管理', action: '录入费用', targetId: result.id, targetType: 'room_expense', detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`, @@ -115,7 +153,7 @@ export class ExpensesController { @Post('room/batch') @RequirePermission('expense:create') - async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: any) { + async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchCreateRoomExpenses(dto, req.user?.id); await logAudit(this.logService, req, { module: '费用管理', action: '批量录入费用', detail: JSON.stringify(dto), @@ -131,7 +169,7 @@ export class ExpensesController { @Delete('room/:id') @RequirePermission('expense:delete') - async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.deleteRoomExpense(id); await logAudit(this.logService, req, { module: '费用管理', action: '归档费用', targetId: id, targetType: 'room_expense', @@ -141,7 +179,7 @@ export class ExpensesController { @Post('room/batch-delete') @RequirePermission('expense:delete') - async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchDeleteRoomExpenses(body.ids || []); await logAudit(this.logService, req, { module: '费用管理', action: '批量归档宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -151,7 +189,7 @@ export class ExpensesController { @Delete('room/:id/permanent') @RequirePermission('expense:purge') - async purgeRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purgeRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purgeRoomExpense(id); await logAudit(this.logService, req, { module: '费用管理', action: '永久删除宿舍费用', targetId: id, targetType: 'room_expense', detail: '物理删除,不可恢复', @@ -161,7 +199,7 @@ export class ExpensesController { @Post('room/batch-permanent-delete') @RequirePermission('expense:purge') - async batchPurgeRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurgeRoomExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurgeRoomExpenses(body.ids || []); await logAudit(this.logService, req, { module: '费用管理', action: '批量永久删除宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -172,7 +210,7 @@ export class ExpensesController { @Put('room/batch-restore') @RequirePermission('expense:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: any) { + async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestoreRoomExpenses(dto.ids); await logAudit(this.logService, req, { module: '费用管理', action: '批量恢复宿舍费用', detail: `IDs: ${dto.ids.join(',')}`, @@ -185,7 +223,7 @@ export class ExpensesController { async updateRoomExpense( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoomExpenseDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.updateRoomExpense(id, dto); await logAudit(this.logService, req, { @@ -196,7 +234,7 @@ export class ExpensesController { @Post('personal') @RequirePermission('expense:create') - async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: any) { + async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: AuthenticatedRequest) { const result = await this.service.createPersonalExpense(dto, req.user?.id); await logAudit(this.logService, req, { module: '费用管理', action: '录入费用', detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`, @@ -212,7 +250,7 @@ export class ExpensesController { @Delete('personal/:id') @RequirePermission('expense:delete') - async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.deletePersonalExpense(id); await logAudit(this.logService, req, { module: '费用管理', action: '归档费用', targetId: id, @@ -222,7 +260,7 @@ export class ExpensesController { @Post('personal/batch-delete') @RequirePermission('expense:delete') - async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchDeletePersonalExpenses(body.ids || []); await logAudit(this.logService, req, { module: '费用管理', action: '批量归档个人费用', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -232,7 +270,7 @@ export class ExpensesController { @Delete('personal/:id/permanent') @RequirePermission('expense:purge') - async purgePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purgePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purgePersonalExpense(id); await logAudit(this.logService, req, { module: '费用管理', action: '永久删除个人费用', targetId: id, targetType: 'personal_expense', detail: '物理删除,不可恢复', @@ -242,7 +280,7 @@ export class ExpensesController { @Post('personal/batch-permanent-delete') @RequirePermission('expense:purge') - async batchPurgePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurgePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurgePersonalExpenses(body.ids || []); await logAudit(this.logService, req, { module: '费用管理', action: '批量永久删除个人费用', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -253,7 +291,7 @@ export class ExpensesController { @Put('personal/batch-restore') @RequirePermission('expense:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: any) { + async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestorePersonalExpenses(dto.ids); await logAudit(this.logService, req, { module: '费用管理', action: '批量恢复个人费用', detail: `IDs: ${dto.ids.join(',')}`, @@ -266,7 +304,7 @@ export class ExpensesController { async updatePersonalExpense( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdatePersonalExpenseDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.updatePersonalExpense(id, dto); await logAudit(this.logService, req, { @@ -314,11 +352,11 @@ export class ExpensesController { @Post('utility/import') @RequirePermission('expense:create') @UseInterceptors(FileInterceptor('file')) - async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; - const rows: any[] = []; + const rows: UtilityImportRow[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; // 跳过表头 const roomNumber = readCellStr(row.getCell(3)); @@ -379,11 +417,11 @@ export class ExpensesController { @Post('personal/import') @RequirePermission('expense:create') @UseInterceptors(FileInterceptor('file')) - async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; - const rows: any[] = []; + const rows: PersonalImportRow[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; const studentName = readCellStr(row.getCell(1)); @@ -417,7 +455,7 @@ export class ExpensesController { { header: '说明', key: 'description', width: 30 }, ]; ws.getRow(1).font = { bold: true }; - data.forEach((d: any) => { + data.forEach((d: PersonalExpense) => { ws.addRow({ studentName: d.student?.name || '', expenseType: d.expenseType, diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index 0a776928..1b0d666f 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -14,6 +14,9 @@ import { import { BillsService } from '../bills/bills.service'; import { ExpenseOperationsService } from './expense-operations.service'; +/** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */ +type RawScalarRow = Record; + @Injectable() export class ExpensesService { @@ -154,7 +157,7 @@ export class ExpensesService { const roomRows = await roomQb .orderBy('e.createdAt', 'DESC') .limit(limit) - .getRawMany>(); + .getRawMany(); const personalQb = this.personalExpRepo .createQueryBuilder('e') @@ -186,7 +189,7 @@ export class ExpensesService { const personalRows = await personalQb .orderBy('e.createdAt', 'DESC') .limit(limit) - .getRawMany>(); + .getRawMany(); return { roomExpenses: roomRows.map((row) => ({ diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts index 7a618d42..6809fd5c 100644 --- a/apps/server/src/imports/imports.service.spec.ts +++ b/apps/server/src/imports/imports.service.spec.ts @@ -8,7 +8,6 @@ import { ImportRun } from './entities/import-run.entity'; import { ImportStep } from './entities/import-step.entity'; import { ImportRow } from './entities/import-row.entity'; import { ImportsService } from './imports.service'; -import * as workbookModule from './imports.workbook'; import type { ParsedImportFile } from './imports.types'; function makeRowsRepo() { diff --git a/apps/server/src/imports/imports.service.ts b/apps/server/src/imports/imports.service.ts index 92e51364..b332356e 100644 --- a/apps/server/src/imports/imports.service.ts +++ b/apps/server/src/imports/imports.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { ImportRun } from './entities/import-run.entity'; diff --git a/apps/server/src/imports/imports.workbook.spec.ts b/apps/server/src/imports/imports.workbook.spec.ts index f8a7c507..137f0320 100644 --- a/apps/server/src/imports/imports.workbook.spec.ts +++ b/apps/server/src/imports/imports.workbook.spec.ts @@ -7,7 +7,7 @@ import { parseSheets } from './imports.workbook'; async function xlsxBuffer(rows: Array>): Promise { const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet('名单'); - rows.forEach((row, index) => worksheet.addRow(row)); + rows.forEach((row, _index) => worksheet.addRow(row)); return (await workbook.xlsx.writeBuffer()) as Buffer; } diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts index a291654d..0c300d5b 100644 --- a/apps/server/src/integration/config/integration-config.service.ts +++ b/apps/server/src/integration/config/integration-config.service.ts @@ -11,6 +11,12 @@ import { SaveIntegrationConfigDto, } from './dto/config.dto'; +/** 第三方配置在 content JSON 中的存储结构。 */ +interface StoredConfigShape { + config?: unknown; + appSecret?: unknown; +} + @Injectable() export class IntegrationConfigService { private readonly logger = new Logger(IntegrationConfigService.name); @@ -22,6 +28,18 @@ export class IntegrationConfigService { private readonly detailRepo: Repository, ) {} + /** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */ + private stringify(value: unknown): string { + return String(value); + } + + /** 解析 content JSON 并取 config 段(无 config 时回退整个对象)。 */ + private parseStoredConfig(content: string): Record { + const parsed = JSON.parse(content) as StoredConfigShape; + const rawCfg = parsed.config || parsed; + return rawCfg && typeof rawCfg === 'object' ? (rawCfg as Record) : {}; + } + /** 获取或创建主配置(全局单例) */ private async ensureConfig(): Promise { let config = await this.configRepo.findOne({ where: { type: 'THIRD' } }); @@ -80,8 +98,7 @@ export class IntegrationConfigService { if (existingDetail && existingDetail.content) { if (!finalConfig.appSecret) { try { - const oldParsed = JSON.parse(existingDetail.content); - const oldCfg = oldParsed.config || oldParsed; + const oldCfg = this.parseStoredConfig(existingDetail.content); if (oldCfg.appSecret) finalConfig.appSecret = oldCfg.appSecret; } catch { // ignore @@ -156,7 +173,7 @@ export class IntegrationConfigService { * 供同步逻辑使用:读原始(未脱敏)配置。 * 返回 { agentId, appSecret, corpId, appId? } 或 null。 */ - async getRawConfig(type: string): Promise | null> { + async getRawConfig(type: string): Promise | null> { const config = await this.ensureConfig(); const detailType = this.getDetailType(type); const detail = await this.detailRepo.findOne({ @@ -164,8 +181,7 @@ export class IntegrationConfigService { }); if (!detail || !detail.content) return null; try { - const parsed = JSON.parse(detail.content); - return parsed.config || parsed; + return this.parseStoredConfig(detail.content); } catch { return null; } @@ -192,8 +208,8 @@ export class IntegrationConfigService { ): Promise { try { if (type.toUpperCase() === 'DINGTALK') { - const appKey = String(config.agentId || ''); - const appSecret = String(config.appSecret || ''); + const appKey = this.stringify(config.agentId || ''); + const appSecret = this.stringify(config.appSecret || ''); if (!appKey || !appSecret) return null; return await this.fetchDingTalkToken(appKey, appSecret); } @@ -227,10 +243,9 @@ export class IntegrationConfigService { private parseAndMaskConfig(content: string | null): unknown { if (!content) return {}; try { - const parsed = JSON.parse(content); - const source = parsed.config || parsed; + const source = this.parseStoredConfig(content); if (!source || typeof source !== 'object' || Array.isArray(source)) return {}; - const { appSecret: _appSecret, ...masked } = source as Record; + const { appSecret: _appSecret, ...masked } = source; return masked; } catch { return {}; diff --git a/apps/server/src/integration/wecom.service.ts b/apps/server/src/integration/wecom.service.ts index bc59189d..ee94223f 100644 --- a/apps/server/src/integration/wecom.service.ts +++ b/apps/server/src/integration/wecom.service.ts @@ -56,7 +56,7 @@ export class WeComService { const corpSecret = process.env.WECOM_CORP_SECRET!; const url = `${WECOM_API_BASE}${WECOM_TOKEN_PATH}?corpid=${corpId}&corpsecret=${corpSecret}`; const res = await fetch(url); - const body: WeComTokenResponse = await res.json(); + const body = (await res.json()) as WeComTokenResponse; if (body.errcode !== 0) { throw new Error(`WeCom gettoken failed: ${body.errmsg} (${body.errcode})`); } @@ -72,7 +72,7 @@ export class WeComService { const all: WeComDeptListResponse['department'] = []; const url = `${WECOM_API_BASE}${WECOM_DEPARTMENT_PATH}?access_token=${token}&id=${parentId}`; const res = await fetch(url); - const body: WeComDeptListResponse = await res.json(); + const body = (await res.json()) as WeComDeptListResponse; if (body.errcode !== 0) { if (body.errcode === 60003) return all; throw new Error(`WeCom department list failed: ${body.errmsg} (${body.errcode})`); @@ -93,7 +93,7 @@ export class WeComService { ): Promise> { const url = `${WECOM_API_BASE}${WECOM_USER_PATH}?access_token=${token}&department_id=${deptId}&fetch_child=1`; const res = await fetch(url); - const body: WeComUserListResponse = await res.json(); + const body = (await res.json()) as WeComUserListResponse; if (body.errcode !== 0) { throw new Error(`WeCom user list failed: ${body.errmsg} (${body.errcode})`); } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1431280e..63b96536 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -11,6 +11,7 @@ async function bootstrap() { app.setGlobalPrefix('api'); app.enableCors(); app.use(helmet()); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- compression 经 export= 声明,eslint 类型解析受限 app.use(compression()); await app.listen(process.env.PORT ?? 3000); diff --git a/apps/server/src/migrations/1786000000000-WidenImportSheetsJson.ts b/apps/server/src/migrations/1786000000000-WidenImportSheetsJson.ts index 715fee82..e52cb0d2 100644 --- a/apps/server/src/migrations/1786000000000-WidenImportSheetsJson.ts +++ b/apps/server/src/migrations/1786000000000-WidenImportSheetsJson.ts @@ -8,10 +8,10 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class WidenImportSheetsJson1786000000000 implements MigrationInterface { async up(queryRunner: QueryRunner): Promise { if (!(await queryRunner.hasTable('import_runs'))) return; - const rows = await queryRunner.query( + const rows = (await queryRunner.query( `SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'import_runs' AND COLUMN_NAME = 'sheets_json'`, - ); + )) as Array<{ DATA_TYPE: string }>; const current = rows?.[0]?.DATA_TYPE as string | undefined; if (current && current.toLowerCase() !== 'mediumtext') { await queryRunner.query('ALTER TABLE `import_runs` MODIFY COLUMN `sheets_json` MEDIUMTEXT'); diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index 9ee22772..f16f4b93 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -37,6 +37,12 @@ import { parseOccupancyImportWorksheet, } from './occupancy-import-template'; +interface AuthenticatedRequest { + user?: { id: number; username: string }; + headers?: Record; + connection?: { remoteAddress?: string }; +} + @UseGuards(JwtAuthGuard) @Controller('occupancies') export class OccupanciesController { @@ -66,7 +72,7 @@ export class OccupanciesController { @Put('batch-restore') @RequirePermission('occupancy:delete') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { + async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestore(dto.ids); await logAudit(this.logService, req, { module: '入住管理', action: '批量恢复入住记录', detail: `IDs: ${dto.ids.join(',')}`, @@ -76,7 +82,7 @@ export class OccupanciesController { @Post('batch-check-out') @RequirePermission('occupancy:checkout') - async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: any) { + async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchCheckOut(dto); await logAudit(this.logService, req, { module: '入住管理', action: '批量退宿', detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`, @@ -86,7 +92,7 @@ export class OccupanciesController { @Post('check-in') @RequirePermission('occupancy:checkin') - async checkIn(@Body() dto: CheckInDto, @Request() req: any) { + async checkIn(@Body() dto: CheckInDto, @Request() req: AuthenticatedRequest) { const result = await this.service.checkIn(dto, req.user?.id); await logAudit(this.logService, req, { module: '入住管理', action: '办理入住', targetId: result.id, targetType: 'occupancy', detail: `学生${dto.studentId} 入住房间${dto.roomId}`, @@ -110,7 +116,7 @@ export class OccupanciesController { @Put(':id/check-out') @RequirePermission('occupancy:checkout') - async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: any) { + async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: AuthenticatedRequest) { const result = await this.service.checkOut(+id, dto); await logAudit(this.logService, req, { module: '入住管理', action: '办理退宿', targetId: +id, targetType: 'occupancy', @@ -134,7 +140,7 @@ export class OccupanciesController { @Put(':id/transfer') @RequirePermission('occupancy:transfer') - async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: any) { + async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.transferRoom(+id, dto); await logAudit(this.logService, req, { module: '入住管理', action: '调换宿舍', targetId: +id, targetType: 'occupancy', detail: `换到房间${dto.newRoomId}`, @@ -144,7 +150,7 @@ export class OccupanciesController { @Delete(':id') @RequirePermission('occupancy:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '入住管理', action: '归档入住记录', targetId: +id, targetType: 'occupancy', @@ -154,7 +160,7 @@ export class OccupanciesController { @Post('batch-delete') @RequirePermission('occupancy:delete') - async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { + async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRemove(body.ids || []); await logAudit(this.logService, req, { module: '入住管理', action: '批量归档入住记录', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -164,7 +170,7 @@ export class OccupanciesController { @Delete(':id/permanent') @RequirePermission('occupancy:purge') - async purge(@Param('id') id: string, @Request() req: any) { + async purge(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '入住管理', action: '永久删除入住记录', targetId: +id, targetType: 'occupancy', detail: '物理删除,不可恢复', @@ -174,7 +180,7 @@ export class OccupanciesController { @Post('batch-permanent-delete') @RequirePermission('occupancy:purge') - async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurge(body.ids || []); await logAudit(this.logService, req, { module: '入住管理', action: '批量永久删除入住记录', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -257,7 +263,7 @@ export class OccupanciesController { @UseInterceptors(FileInterceptor('file')) async importCheckIn( @UploadedFile() file: Express.Multer.File, - @Request() req: any, + @Request() req: AuthenticatedRequest, @Query('autoDeposit') autoDeposit?: string, @Query('depositAmount') depositAmount?: string, ) { diff --git a/apps/server/src/occupancies/occupancy-import-template.ts b/apps/server/src/occupancies/occupancy-import-template.ts index 6437e814..12a00cc5 100644 --- a/apps/server/src/occupancies/occupancy-import-template.ts +++ b/apps/server/src/occupancies/occupancy-import-template.ts @@ -68,6 +68,7 @@ function cellText(cell: ExcelJS.Cell | undefined): string { if (typeof cell.value === 'object' && 'text' in cell.value) { return String(cell.value.text).trim(); } + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- exceljs CellValue 联合类型含富文本/公式对象,原样保留其字符串化结果 return String(cell.value).trim(); } diff --git a/apps/server/src/occupancies/occupancy-import.service.ts b/apps/server/src/occupancies/occupancy-import.service.ts index f1f9ec85..0fe34f28 100644 --- a/apps/server/src/occupancies/occupancy-import.service.ts +++ b/apps/server/src/occupancies/occupancy-import.service.ts @@ -1,10 +1,21 @@ import { Injectable, BadRequestException } from '@nestjs/common'; -import { DataSource, IsNull } from 'typeorm'; +import { DataSource, IsNull, DeepPartial } from 'typeorm'; import { Occupancy, Room, Student, Deposit, Bed, Locker, Organization } from '../entities'; import { RoomsService } from '../rooms/rooms.service'; class ImportRowSkipped extends Error {} +type StudentUpdateFields = Pick< + Student, + | 'studentNo' + | 'idNumber' + | 'gender' + | 'ethnicity' + | 'emergencyContact' + | 'emergencyPhone' + | 'supervisor' +>; + @Injectable() export class OccupancyImportService { constructor(private dataSource: DataSource) {} @@ -87,7 +98,7 @@ export class OccupancyImportService { ); } else { // 更新已有学生的缺失信息 - const updates: any = {}; + const updates: Partial = {}; if (!student.studentNo && row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); @@ -191,7 +202,7 @@ export class OccupancyImportService { } // 6. 创建入住记录 - const occData: any = { + const occData: DeepPartial = { studentId: student.id, roomId: room.id, checkInDate, @@ -258,11 +269,11 @@ export class OccupancyImportService { imported++; depositsCreated += result.depositsCreated; - } catch (e: any) { + } catch (e: unknown) { errors.push( e instanceof ImportRowSkipped ? e.message - : `第${rowNum}行: ${row.name} 导入失败 - ${e.message}`, + : `第${rowNum}行: ${row.name} 导入失败 - ${e instanceof Error ? e.message : String(e)}`, ); skipped++; } diff --git a/apps/server/src/operation-logs/operation-logs.controller.ts b/apps/server/src/operation-logs/operation-logs.controller.ts index 491ad0df..5541cdac 100644 --- a/apps/server/src/operation-logs/operation-logs.controller.ts +++ b/apps/server/src/operation-logs/operation-logs.controller.ts @@ -4,6 +4,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { extractRequestInfo } from '../common/request-utils'; import { CreateAuditLogDto, QueryOperationLogsDto } from './dto/operation-log.dto'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + headers?: Record; + connection?: { remoteAddress?: string }; +} @UseGuards(JwtAuthGuard) @Controller('operation-logs') @@ -21,7 +28,7 @@ export class OperationLogsController { @RequirePermission('log:create') async createAuditLog( @Body() body: CreateAuditLogDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); return this.service.log({ diff --git a/apps/server/src/organizations/organizations.controller.ts b/apps/server/src/organizations/organizations.controller.ts index f260c485..a5cee237 100644 --- a/apps/server/src/organizations/organizations.controller.ts +++ b/apps/server/src/organizations/organizations.controller.ts @@ -17,6 +17,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + headers?: Record; + connection?: { remoteAddress?: string }; +} @UseGuards(JwtAuthGuard) @Controller('organizations') @@ -49,7 +56,7 @@ export class OrganizationsController { @Post() @RequirePermission('organization:create') - async create(@Body() dto: CreateOrganizationDto, @Request() req: any) { + async create(@Body() dto: CreateOrganizationDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); await this.logService.log({ @@ -68,7 +75,7 @@ export class OrganizationsController { @Put(':id') @RequirePermission('organization:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateOrganizationDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateOrganizationDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); await this.logService.log({ @@ -87,7 +94,7 @@ export class OrganizationsController { @Delete(':id') @RequirePermission('organization:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); await this.logService.log({ @@ -105,7 +112,7 @@ export class OrganizationsController { @Delete(':id/permanent') @RequirePermission('organization:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.purge(+id); await this.logService.log({ diff --git a/apps/server/src/rbac/rbac.controller.ts b/apps/server/src/rbac/rbac.controller.ts index 68d1c315..c62e5b05 100644 --- a/apps/server/src/rbac/rbac.controller.ts +++ b/apps/server/src/rbac/rbac.controller.ts @@ -26,6 +26,13 @@ import { RequirePermission } from '../auth/decorators/permission.decorator'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; +/** JwtAuthGuard 保证 req.user 存在(类级 @UseGuards) */ +interface AuthenticatedRequest { + user: { id: number; username: string }; + headers?: Record; + connection?: { remoteAddress?: string }; +} + @UseGuards(JwtAuthGuard) @Controller('rbac') export class RbacController { @@ -48,7 +55,7 @@ export class RbacController { @Post('roles') @RequirePermission('role:create') - async createRole(@Body() dto: CreateRoleDto, @Request() req: any) { + async createRole(@Body() dto: CreateRoleDto, @Request() req: AuthenticatedRequest) { const result = await this.rbacService.createRole(dto); await logAudit(this.logService, req, { module: 'RBAC', action: '创建角色', detail: `角色: ${dto.name}`, @@ -58,29 +65,29 @@ export class RbacController { @Put('roles/:id') @RequirePermission('role:edit') - async updateRole(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoleDto, @Request() req: any) { + async updateRole(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoleDto, @Request() req: AuthenticatedRequest) { try { const result = await this.rbacService.updateRole(+id, dto); await logAudit(this.logService, req, { module: 'RBAC', action: '编辑角色', targetId: +id, targetType: 'role', detail: JSON.stringify(dto), }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @Delete('roles/:id') @RequirePermission('role:delete') - async deleteRole(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async deleteRole(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { try { const result = await this.rbacService.deleteRole(+id); await logAudit(this.logService, req, { module: 'RBAC', action: '停用角色', targetId: +id, targetType: 'role', }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @@ -105,43 +112,43 @@ export class RbacController { @Post('users') @RequirePermission('user:create') - async createUser(@Body() dto: CreateUserDto, @Request() req: any) { + async createUser(@Body() dto: CreateUserDto, @Request() req: AuthenticatedRequest) { try { const result = await this.rbacService.createUser(dto); await logAudit(this.logService, req, { module: '账号', action: '创建账号', detail: `用户名: ${dto.username}`, }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @Put('users/:id') @RequirePermission('user:edit') - async updateUser(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto, @Request() req: any) { + async updateUser(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto, @Request() req: AuthenticatedRequest) { try { const result = await this.rbacService.updateUser(+id, dto); await logAudit(this.logService, req, { module: '账号', action: '更新账号', targetId: +id, targetType: 'user', detail: JSON.stringify(dto), }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @Put('users/:id/password') @RequirePermission('user:reset-password') - async resetPassword(@Param('id', ParseIntPipe) id: number, @Body() dto: ResetPasswordDto, @Request() req: any) { + async resetPassword(@Param('id', ParseIntPipe) id: number, @Body() dto: ResetPasswordDto, @Request() req: AuthenticatedRequest) { try { const result = await this.rbacService.resetPassword(+id, dto.password); await logAudit(this.logService, req, { module: '账号', action: '重置密码', targetId: +id, targetType: 'user', }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @@ -169,9 +176,9 @@ export class RbacController { @Delete('users/:id/permanent') @RequirePermission('user:purge') - async purgeUser(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purgeUser(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { try { - const result = await this.rbacService.purgeUser(+id, req.user?.id); + const result = await this.rbacService.purgeUser(+id, req.user.id); await logAudit(this.logService, req, { module: '账号', action: '永久删除用户', targetId: +id, targetType: 'user', detail: '物理删除,不可恢复', }); @@ -215,7 +222,7 @@ export class RbacController { async updateUserProfile( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateProfileDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { try { const result = await this.rbacService.updateUserProfile(+id, dto); @@ -223,15 +230,15 @@ export class RbacController { module: '账号', action: '更新资料', targetId: +id, targetType: 'user', }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @Get('teacher-workspace') @RequirePermission('teacher-workspace:view') - async getTeacherWorkspace(@Request() req: any) { - return this.rbacService.getTeacherWorkspace(req.user?.id); + async getTeacherWorkspace(@Request() req: AuthenticatedRequest) { + return this.rbacService.getTeacherWorkspace(req.user.id); } @Get('teachers') diff --git a/apps/server/src/rooms/room-query.service.ts b/apps/server/src/rooms/room-query.service.ts index 4e00862a..92f3ba1a 100644 --- a/apps/server/src/rooms/room-query.service.ts +++ b/apps/server/src/rooms/room-query.service.ts @@ -8,6 +8,41 @@ import { RoomInspectionsService } from './room-inspections.service'; import { occupancyWhereOnDate } from './room-occupancy-date'; import { parseRoomNumber } from './room-number'; +/** getRawMany 原始行:驱动可能返回 string 或 number,故标量字段用联合类型 */ +interface RoomSearchRawRow { + room_id: string | number; + room_room_number: string; + room_building: string | null; + room_floor: string | number | null; + room_capacity: string | number; + room_room_type: string | null; + room_status: string; +} + +interface RoomOccupancySummaryRawRow { + roomId: string | number; + roomNumber: string; + occupied: string | number; + capacity: string | number; +} + +/** getRoomVisual 中按宿舍分组的入住记录展示字段 */ +interface RoomVisualOccupant { + studentId: number; + occupancyId: number; + studentName: string; + bedId: number | null; + bedNumber: string | null; + checkInDate: string; + billingStartDate: string; + days: number; + organization: string | null; + supervisor: string | null; + organizationId: number | null; + organizationName: string | null; + organizationColor: string | null; +} + @Injectable() export class RoomQueryService { constructor( @@ -40,7 +75,7 @@ export class RoomQueryService { ]) .orderBy('room.roomNumber', 'ASC') .limit(Math.max(1, Math.min(query.limit ?? 20, 50))) - .getRawMany(); + .getRawMany(); return rows.map((row) => ({ id: Number(row.room_id), roomNumber: String(row.room_room_number), @@ -68,7 +103,7 @@ export class RoomQueryService { .groupBy('room.id') .orderBy('room.roomNumber', 'ASC') .limit(Math.max(1, Math.min(query.limit ?? 20, 50))) - .getRawMany(); + .getRawMany(); return rows.map((row) => ({ roomId: Number(row.roomId), roomNumber: String(row.roomNumber), @@ -96,7 +131,7 @@ export class RoomQueryService { }); // 按roomId分组入住记录 - const occMap = new Map(); + const occMap = new Map(); // days(已住天数)相对目标日期计算,而非固定今天,历史视图才准确。 const refTime = new Date(targetDate).getTime(); for (const occ of occupancies) { @@ -153,18 +188,18 @@ export class RoomQueryService { const inspectionByOccupancyId = new Map( (inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]), ); - const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))]; + const orgs = [...new Set(occ.map((o) => o.organization).filter(Boolean))]; let orgLabel: string | null = null; if (orgs.length > 0 && occ.length > 0) { - const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]); + const allSameOrg = occ.every((o) => o.organization && o.organization === orgs[0]); orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`; } const organizationColors = [ - ...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)), + ...new Set(occ.map((o) => o.organizationColor).filter(Boolean)), ]; const organizationColor: string | null = organizationColors.length === 1 ? organizationColors[0] : null; - const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))]; + const organizationIds = [...new Set(occ.map((o) => o.organizationId).filter(Boolean))]; return { id: room.id, roomNumber: room.roomNumber, diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index a7db9a86..dde872cd 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -31,6 +31,21 @@ import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; import * as ExcelJS from 'exceljs'; +interface AuthenticatedRequest { + user?: { id: number; username: string }; + headers?: Record; + connection?: { remoteAddress?: string }; +} + +/** + * exceljs 单元格标量文本。保持原 `String(value || '')` 语义。 + * CellValue 联合类型含富文本/超链接对象,实际导入数据均为标量,故在辅助函数内局部豁免。 + */ +function cellValueText(value: unknown): string { + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- exceljs CellValue 联合类型含富文本对象,实际导入数据为标量 + return String(value || ''); +} + @UseGuards(JwtAuthGuard) @Controller('rooms') export class RoomsController { @@ -64,7 +79,7 @@ export class RoomsController { @Put('batch-restore') @RequirePermission('room:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { + async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestore(dto.ids); await logAudit(this.logService, req, { module: '宿舍', action: '批量恢复宿舍', detail: `IDs: ${dto.ids.join(',')}`, @@ -78,7 +93,7 @@ export class RoomsController { @Param('roomId') roomId: string, @Param('date') date: string, @Body() dto: UpdateRoomInspectionDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.inspectionsService.submit( +roomId, @@ -269,7 +284,7 @@ export class RoomsController { @Post() @RequirePermission('room:create') - async create(@Body() dto: CreateRoomDto, @Request() req: any) { + async create(@Body() dto: CreateRoomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto); await logAudit(this.logService, req, { module: '宿舍', action: '添加宿舍', detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}人`, @@ -279,7 +294,7 @@ export class RoomsController { @Put(':id') @RequirePermission('room:edit') - async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @Request() req: any) { + async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '宿舍', action: '编辑宿舍', targetId: +id, targetType: 'room', detail: JSON.stringify(dto), @@ -289,7 +304,7 @@ export class RoomsController { @Delete(':id') @RequirePermission('room:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '宿舍', action: '归档宿舍', targetId: +id, targetType: 'room', @@ -299,7 +314,7 @@ export class RoomsController { @Post('batch-delete') @RequirePermission('room:delete') - async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { + async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRemove(body.ids || []); await logAudit(this.logService, req, { module: '宿舍', action: '批量归档宿舍', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -309,7 +324,7 @@ export class RoomsController { @Delete(':id/permanent') @RequirePermission('room:purge') - async purge(@Param('id') id: string, @Request() req: any) { + async purge(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '宿舍', action: '永久删除宿舍', targetId: +id, targetType: 'room', detail: '物理删除,不可恢复', @@ -319,7 +334,7 @@ export class RoomsController { @Post('batch-permanent-delete') @RequirePermission('room:purge') - async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurge(body.ids || []); await logAudit(this.logService, req, { module: '宿舍', action: '批量永久删除宿舍', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -329,7 +344,7 @@ export class RoomsController { @Put(':id/restore') @RequirePermission('room:edit') - async restore(@Param('id') id: string, @Request() req: any) { + async restore(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.restore(+id); await logAudit(this.logService, req, { module: '宿舍', action: '恢复宿舍', targetId: +id, targetType: 'room', @@ -340,7 +355,7 @@ export class RoomsController { @Post('import') @RequirePermission('room:create') @UseInterceptors(FileInterceptor('file')) - async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); @@ -356,7 +371,7 @@ export class RoomsController { }[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; - const rentalCategoryRaw = String(row.getCell(6).value || '') + const rentalCategoryRaw = cellValueText(row.getCell(6).value) .trim() .toLowerCase(); const rentalCategory = @@ -366,11 +381,11 @@ export class RoomsController { const monthlyRateRaw = Number(row.getCell(7).value); const monthlyRate = isNaN(monthlyRateRaw) ? undefined : monthlyRateRaw; rows.push({ - roomNumber: String(row.getCell(1).value || ''), - building: String(row.getCell(2).value || '') || undefined, + roomNumber: cellValueText(row.getCell(1).value), + building: cellValueText(row.getCell(2).value) || undefined, floor: (n => Number.isNaN(n) ? undefined : n)(Number(row.getCell(3).value)), capacity: Number(row.getCell(4).value) || 4, - roomType: String(row.getCell(5).value || '').trim() || undefined, + roomType: cellValueText(row.getCell(5).value).trim() || undefined, rentalCategory, monthlyRate, }); diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index d0fd654a..5232bbde 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository, IsNull, Not, In } from 'typeorm'; +import { DataSource, Repository, IsNull, Not, In, FindOptionsWhere } from 'typeorm'; import { Room } from '../entities/room.entity'; import { Occupancy } from '../entities/occupancy.entity'; @@ -55,7 +55,7 @@ export class RoomsService { } async findAll(query?: { building?: string; includeArchived?: boolean }) { - const where: any = {}; + const where: FindOptionsWhere = {}; if (query?.building) where.building = query.building; if (!query?.includeArchived) where.status = Not('archived'); return this.repo.find({ where, order: { roomNumber: 'ASC' } }); @@ -78,10 +78,10 @@ export class RoomsService { } async getRoomOverview(query?: { includeArchived?: boolean }) { - const where: any = {}; + const where: FindOptionsWhere = {}; if (!query?.includeArchived) where.status = Not('archived'); const rooms = await this.repo.find({ where, order: { building: 'ASC', roomNumber: 'ASC' } }); - const result: any[] = []; + const result: Array = []; for (const room of rooms) { const count = await this.occRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() }, diff --git a/apps/server/src/schedules/schedule-queries.service.ts b/apps/server/src/schedules/schedule-queries.service.ts index d146f113..c8356366 100644 --- a/apps/server/src/schedules/schedule-queries.service.ts +++ b/apps/server/src/schedules/schedule-queries.service.ts @@ -6,6 +6,11 @@ import type { WeeklyViewQueryDto } from './dto/schedule.dto'; const ACTIVE_SCHEDULE_STATUS = 'active'; +/** String() 包装:避免 raw 行值(unknown 收窄为对象类型)触发 no-base-to-string。 */ +function stringify(value: unknown): string { + return String(value); +} + @Injectable() export class ScheduleQueriesService { constructor( @@ -106,18 +111,18 @@ export class ScheduleQueriesService { return rows.map((row) => ({ id: Number(row.cs_id), classId: row.cs_class_id == null ? null : Number(row.cs_class_id), - className: row.class_name == null ? null : String(row.class_name), + className: row.class_name == null ? null : stringify(row.class_name), classroomId: Number(row.cs_classroom_id), - classroomName: row.classroom_name == null ? null : String(row.classroom_name), + classroomName: row.classroom_name == null ? null : stringify(row.classroom_name), weekDay: Number(row.cs_week_day), - startTime: String(row.cs_start_time), - endTime: String(row.cs_end_time), - subject: String(row.cs_subject), - teacherName: row.teacher_name == null ? null : String(row.teacher_name), - startDate: String(row.cs_start_date), - endDate: String(row.cs_end_date), - scheduleType: String(row.cs_schedule_type), - status: String(row.cs_status), + startTime: stringify(row.cs_start_time), + endTime: stringify(row.cs_end_time), + subject: stringify(row.cs_subject), + teacherName: row.teacher_name == null ? null : stringify(row.teacher_name), + startDate: stringify(row.cs_start_date), + endDate: stringify(row.cs_end_date), + scheduleType: stringify(row.cs_schedule_type), + status: stringify(row.cs_status), })); } diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts index 276e1c85..e1ec2902 100644 --- a/apps/server/src/students/dto/student.dto.ts +++ b/apps/server/src/students/dto/student.dto.ts @@ -97,7 +97,7 @@ export class QueryStudentDto { status?: string; @IsOptional() - @Transform(({ value }) => { + @Transform(({ value }: { value: unknown }) => { if (typeof value === 'boolean') return value; if (value === 'true' || value === '1') return true; if (value === 'false' || value === '0') return false; diff --git a/apps/server/src/students/student-import.ts b/apps/server/src/students/student-import.ts index 09627715..eee277d2 100644 --- a/apps/server/src/students/student-import.ts +++ b/apps/server/src/students/student-import.ts @@ -166,6 +166,8 @@ function cellToText(cell: ExcelJS.Cell): string { const value = getCellPrimitiveValue(cell); if (value === null || value === undefined) return ''; if (value instanceof Date) return formatDate(value); + // 对象值(错误单元格/共享公式等)保留既有 String() 行为,不做类型收窄 + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- 对象值保留既有 '[object Object]' 输出 return String(value).trim(); } @@ -186,7 +188,10 @@ function parseDateText(cell: ExcelJS.Cell): string | undefined { const value = getCellPrimitiveValue(cell); if (value instanceof Date) return formatDate(value); if (typeof value === 'number') return excelSerialToDate(value); - const text = value === null || value === undefined ? '' : String(value).trim(); + if (value === null || value === undefined) return undefined; + // 对象值(错误单元格等)保留既有 String() 行为 + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- 对象值保留既有 String() 语义 + const text = String(value).trim(); if (!text) return undefined; const normalized = text.replace(/[/.]/g, '-'); const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u); diff --git a/apps/server/src/students/students.agent.service.ts b/apps/server/src/students/students.agent.service.ts index 34f446b0..cf829ef7 100644 --- a/apps/server/src/students/students.agent.service.ts +++ b/apps/server/src/students/students.agent.service.ts @@ -5,6 +5,17 @@ import { Student } from '../entities/student.entity'; import { ClassStudent } from '../entities/class-student.entity'; import type { StudentAccessScope } from './student-access-scope'; +/** getRawMany/getRawOne 原始行(select 别名即原始键名;可空列按 NULL 处理) */ +interface AgentStudentRawRow { + student_id: number; + student_name: string; + student_student_no: string | null; + student_gender: string | null; + student_status: string; + student_organization_id: number; + organization_name: string | null; +} + @Injectable() export class StudentsAgentService { /** @@ -84,13 +95,13 @@ export class StudentsAgentService { qb.orderBy('student.createdAt', 'DESC').take(limit); - const rows: Record[] = await qb.getRawMany(); + const rows = await qb.getRawMany(); if (rows.length === 0) return []; // Second bounded query: classIds only for the returned student ids. // For teacher scope, the class filter MUST be re-applied so the // teacher only sees classIds they are assigned to. - const studentIds = rows.map((r) => r.student_id as number); + const studentIds = rows.map((r) => r.student_id); const csQb = this.classStudentRepo .createQueryBuilder('cs') .select(['cs.studentId', 'cs.classId']) @@ -104,24 +115,24 @@ export class StudentsAgentService { ); } - const classRows = await csQb.getRawMany(); + const classRows = await csQb.getRawMany<{ cs_student_id: number; cs_class_id: number }>(); const classMap = new Map(); - for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) { + for (const cr of classRows) { const sid = cr.cs_student_id; if (!classMap.has(sid)) classMap.set(sid, []); classMap.get(sid)!.push(cr.cs_class_id); } return rows.map((r) => ({ - id: r.student_id as number, - name: r.student_name as string, - studentNo: (r.student_student_no as string) ?? '', - gender: (r.student_gender as string) ?? '', - status: r.student_status as string, - organizationId: r.student_organization_id as number, - organizationName: (r.organization_name as string) ?? '', - classIds: classMap.get(r.student_id as number) ?? [], + id: r.student_id, + name: r.student_name, + studentNo: r.student_student_no ?? '', + gender: r.student_gender ?? '', + status: r.student_status, + organizationId: r.student_organization_id, + organizationName: r.organization_name ?? '', + classIds: classMap.get(r.student_id) ?? [], })); } @@ -158,7 +169,7 @@ export class StudentsAgentService { this.applyStudentScope(qb, scope); - const row = await qb.getRawOne(); + const row = await qb.getRawOne(); if (!row) return null; // For teacher scope, re-apply class filter so teacher only sees @@ -176,17 +187,17 @@ export class StudentsAgentService { ); } - const classRows = await csQb.getRawMany(); + const classRows = await csQb.getRawMany<{ cs_class_id: number }>(); return { - id: row.student_id as number, - name: row.student_name as string, - studentNo: (row.student_student_no as string) ?? '', - gender: (row.student_gender as string) ?? '', - status: row.student_status as string, - organizationId: row.student_organization_id as number, - organizationName: (row.organization_name as string) ?? '', - classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id), + id: row.student_id, + name: row.student_name, + studentNo: row.student_student_no ?? '', + gender: row.student_gender ?? '', + status: row.student_status, + organizationId: row.student_organization_id, + organizationName: row.organization_name ?? '', + classIds: classRows.map((cr) => cr.cs_class_id), }; } diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 0920f64d..571f5b63 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -91,7 +91,7 @@ export class StudentsController { @Get('export') @RequirePermission('student:export') - async exportExcel(@Query() query: QueryStudentDto, @Res() res?: Response, @Request() req?: any) { + async exportExcel(@Query() query: QueryStudentDto, @Res() res: Response, @Request() req: AuthenticatedRequest) { const classIds = await this.service.getAccessibleClassIds( req.user.id, this.canManageAllStudents(req), @@ -137,13 +137,13 @@ export class StudentsController { await logAudit(this.logService, req, { module: '学生管理', action: '导出学生', detail: `导出 ${students.length} 名学生`, }); - res!.setHeader( + res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ); - res!.setHeader('Content-Disposition', 'attachment; filename=students.xlsx'); - await workbook.xlsx.write(res!); - res!.end(); + res.setHeader('Content-Disposition', 'attachment; filename=students.xlsx'); + await workbook.xlsx.write(res); + res.end(); } @Get('template') @@ -167,7 +167,7 @@ export class StudentsController { @Post() @RequirePermission('student:create') - async create(@Body() dto: CreateStudentDto, @Request() req: any) { + async create(@Body() dto: CreateStudentDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto); await logAudit(this.logService, req, { module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`, @@ -178,7 +178,7 @@ export class StudentsController { @Put('batch-restore') @RequirePermission('student:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { + async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestore(dto.ids); await logAudit(this.logService, req, { module: '学生管理', action: '批量恢复学生', detail: `IDs: ${dto.ids.join(',')}`, @@ -191,7 +191,7 @@ export class StudentsController { async update( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.update(id, dto); await logAudit(this.logService, req, { @@ -202,7 +202,7 @@ export class StudentsController { @Delete(':id') @RequirePermission('student:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(id); await logAudit(this.logService, req, { module: '学生管理', action: '归档学生', targetId: id, targetType: 'student', @@ -212,7 +212,7 @@ export class StudentsController { @Post('batch-delete') @RequirePermission('student:delete') - async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { + async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRemove(body.ids || []); await logAudit(this.logService, req, { module: '学生管理', action: '批量归档学生', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -222,7 +222,7 @@ export class StudentsController { @Delete(':id/permanent') @RequirePermission('student:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(id); await logAudit(this.logService, req, { module: '学生管理', action: '永久删除学生', targetId: id, targetType: 'student', detail: '物理删除,不可恢复', @@ -232,7 +232,7 @@ export class StudentsController { @Post('batch-permanent-delete') @RequirePermission('student:purge') - async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurge(body.ids || []); await logAudit(this.logService, req, { module: '学生管理', action: '批量永久删除学生', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -242,7 +242,7 @@ export class StudentsController { @Put(':id/restore') @RequirePermission('student:edit') - async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async restore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.restore(id); await logAudit(this.logService, req, { module: '学生管理', action: '恢复学生', targetId: id, targetType: 'student', @@ -253,7 +253,7 @@ export class StudentsController { @Post('import') @RequirePermission('student:import') @UseInterceptors(FileInterceptor('file')) - async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const importData = parseStudentImportWorkbook(workbook); @@ -278,7 +278,7 @@ export class StudentsController { @Post('import-match') @RequirePermission('student:import') @UseInterceptors(FileInterceptor('file')) - async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const importData = parseStudentImportWorkbook(workbook); diff --git a/apps/server/src/sync/jinshuju-rules.ts b/apps/server/src/sync/jinshuju-rules.ts index 6248b3f1..5b35fae6 100644 --- a/apps/server/src/sync/jinshuju-rules.ts +++ b/apps/server/src/sync/jinshuju-rules.ts @@ -28,7 +28,10 @@ export function validateMatchRule(formToken: string, mappings: JinshujuFieldMapp 'emergencyContact', 'emergencyPhone', ]); - for (const [studentField, fieldKey] of Object.entries(mappings)) { + // Object.entries 对无索引签名的接口会回退为 any,这里显式标注条目类型 + for (const [studentField, fieldKey] of Object.entries(mappings) as Array< + [string, string | undefined] + >) { if (!allowedStudentFields.has(studentField)) { throw new ConflictException(`不允许映射学生字段:${studentField}`); } diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index 53325206..b953de67 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -189,7 +189,7 @@ export class SyncService { let matched = 0; let created = 0; await this.dataSource.transaction(async (manager) => { - const orgs = await manager.query( + const orgs = await manager.query>( 'SELECT id FROM organizations WHERE is_host = 1 AND status = ? LIMIT 1', ['active'], ); @@ -199,9 +199,23 @@ export class SyncService { const decision = decisionMap.get(serial); if (!decision || decision.action === 'skip') continue; - const mappedValues = Object.fromEntries( - Object.entries(map) - .map(([studentField, fieldKey]) => [studentField, extractField(entry, fieldKey)]) + // 匹配规则只允许映射以下字符串字段(validateMatchRule 白名单)。 + const mappedValues: Partial< + Pick< + Student, + | 'name' + | 'studentNo' + | 'phone' + | 'idNumber' + | 'gender' + | 'ethnicity' + | 'emergencyContact' + | 'emergencyPhone' + > + > = Object.fromEntries( + // Object.entries 对无索引签名的接口会回退为 any,这里显式标注条目类型 + (Object.entries(map) as Array<[string, string | undefined]>) + .map(([studentField, fieldKey]) => [studentField, extractField(entry, fieldKey)] as const) .filter(([, value]) => value), ); diff --git a/apps/server/src/wallets/wallets.controller.ts b/apps/server/src/wallets/wallets.controller.ts index 6bf43043..e9027f9d 100644 --- a/apps/server/src/wallets/wallets.controller.ts +++ b/apps/server/src/wallets/wallets.controller.ts @@ -3,9 +3,16 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; +import type { AuthenticatedUser } from '../authorization'; import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto'; import { WalletsService } from './wallets.service'; +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} + @UseGuards(JwtAuthGuard) @Controller('wallets') export class WalletsController { @@ -35,7 +42,7 @@ export class WalletsController { @Post('change-balance') @RequirePermission('wallet:edit') - async changeBalance(@Body() dto: ChangeWalletBalanceDto, @Request() req: any) { + async changeBalance(@Body() dto: ChangeWalletBalanceDto, @Request() req: AuthenticatedRequest) { const result = await this.service.changeBalance(dto, req.user?.id); const { ipAddress, userAgent } = extractRequestInfo(req); await this.logService.log({ @@ -55,7 +62,7 @@ export class WalletsController { @Post('batch-change-balance') @RequirePermission('wallet:edit') - async batchChangeBalance(@Body() dto: BatchChangeWalletBalanceDto, @Request() req: any) { + async batchChangeBalance(@Body() dto: BatchChangeWalletBalanceDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchChangeBalance(dto, req.user?.id); const { ipAddress, userAgent } = extractRequestInfo(req); await this.logService.log({ From bcd2d1a559c135b769d690637e940ee64f6c48b4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 09:33:53 +0800 Subject: [PATCH 10/43] =?UTF-8?q?refactor(admin):=20A2UI=20=E5=8F=8C?= =?UTF-8?q?=E8=BD=A8=E6=94=B6=E6=95=9B,=E7=BB=9F=E4=B8=80=20artifact=20?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mergeArtifactIntoMessage 不再派发 legacy 列表,只维护 uiArtifacts - 渲染层从 uiArtifacts 派生 forms/reviews/charts,为空时回退 legacy (历史消息兼容,老 metadata 仅 a2uiForm/a2uiReview/a2uiChart) - sseReducer 删除 ui.form/ui.review/ui.chart 旧事件分支,只消费 ui.artifact(后端过渡期双发,旧事件将被忽略) - types.ts legacy 字段标注 deprecated;契约文档同步现状 - 测试更新:旧事件忽略 + uiArtifacts 合并/恢复断言 aislop scan: 5 引擎 0 issues --- .../components/AiChat/AiMessageContent.tsx | 12 +++- .../message-mappers.integration.test.ts | 12 +++- .../AiChat/provider.integration.test.ts | 58 +++++++++--------- .../admin/src/components/AiChat/sseReducer.ts | 14 ++--- apps/admin/src/components/AiChat/types.ts | 4 ++ .../src/components/AiChat/uiArtifacts.ts | 60 ++++++++++--------- scripts/a2ui-contract.md | 17 +++--- 7 files changed, 98 insertions(+), 79 deletions(-) diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index eb85fd6a..e8344328 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -17,6 +17,7 @@ import { message } from '../../ui/app-message'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; +import { deriveCharts, deriveForms, deriveReviews } from './uiArtifacts'; import { ArtifactErrorBoundary } from './ArtifactErrorBoundary'; import { LiteCodeHighlighter } from './LiteCodeHighlighter'; import { LiteMermaid } from './LiteMermaid'; @@ -226,6 +227,11 @@ export const AiMessageContent: React.FC = ({ const streaming = status === 'loading' || status === 'updating'; const formSubmission = message.metadata?.a2uiSubmit; const reviewSubmission = message.metadata?.a2uiReviewSubmit; + // 统一 artifact 优先,历史消息(仅 legacy 字段)回退 + const forms = deriveForms(message).length > 0 ? deriveForms(message) : (message.forms ?? []); + const reviews = + deriveReviews(message).length > 0 ? deriveReviews(message) : (message.reviews ?? []); + const charts = deriveCharts(message).length > 0 ? deriveCharts(message) : (message.charts ?? []); const sourceMeta = message.metadata?.a2uiSources; const sourceItems = Array.isArray(sourceMeta) ? sourceMeta @@ -374,7 +380,7 @@ export const AiMessageContent: React.FC = ({ onClick={(item) => void handleOpenSource(item as { url?: string })} /> )} - {(message.forms ?? []).map((form) => ( + {(forms ?? []).map((form) => ( = ({ /> ))} - {(message.reviews ?? []).map((review: AiReviewSchema) => ( + {(reviews ?? []).map((review: AiReviewSchema) => ( = ({ /> ))} - {(message.charts ?? []).map((chart: AiChartSchema) => ( + {(charts ?? []).map((chart: AiChartSchema) => ( diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index 5363603f..fe998add 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -78,7 +78,7 @@ describe('AI chat history mapper', () => { expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' }); }); - it('restores uiArtifacts from message metadata and derives legacy lists', () => { + it('restores uiArtifacts from message metadata (统一协议)', () => { const mapped = mapHistoryMessage({ id: 8, role: 'assistant', @@ -120,8 +120,14 @@ describe('AI chat history mapper', () => { }); expect(mapped.message.uiArtifacts).toHaveLength(2); - expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-10', status: 'submitted' }); - expect(mapped.message.reviews?.[0]).toMatchObject({ id: 'review-10', status: 'expired' }); + expect(mapped.message.uiArtifacts?.[0].payload).toMatchObject({ + id: 'form-10', + status: 'submitted', + }); + expect(mapped.message.uiArtifacts?.[1].payload).toMatchObject({ + id: 'review-10', + status: 'expired', + }); }); it('restores a persisted A2UI review from message metadata', () => { diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index a0761d00..00251b7d 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -123,38 +123,35 @@ describe('AI chat SSE message reducer', () => { expect(message.id).toBe(9); }); - it('merges ui.form events into the assistant message by id', () => { - const form = { - id: 'form-1', - title: '新增学生', - submitLabel: '提交创建', - fields: [ - { name: 'name', label: '姓名', type: 'input', required: true }, - { name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: 'male' }] }, - ], - }; + it('ignores legacy ui.form events and only consumes ui.artifact (过渡期双发)', () => { let message = reduceAiSseMessage(undefined, { event: 'ui.form', - data: JSON.stringify({ messageId: 8, form }), + data: JSON.stringify({ messageId: 8, form: { id: 'form-1', title: '新增学生', fields: [] } }), }); + // 旧事件不再写入 legacy 列表 + expect(message.forms).toHaveLength(0); + expect(message.uiArtifacts).toHaveLength(0); + message = reduceAiSseMessage(message, { - event: 'ui.form', - data: JSON.stringify({ messageId: 8, form: { ...form, id: 'form-1' } }), - }); - message = reduceAiSseMessage(message, { - event: 'ui.form', + event: 'ui.artifact', data: JSON.stringify({ messageId: 8, - form: { id: 'form-2', title: '入住确认', fields: [] }, + artifact: { + id: 'form-1', + type: 'form', + status: 'pending', + messageId: 8, + conversationId: 3, + payload: { id: 'form-1', title: '新增学生', fields: [] }, + }, }), }); - expect(message.forms).toHaveLength(2); - expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' }); - expect(message.forms?.[1]).toMatchObject({ id: 'form-2' }); + expect(message.uiArtifacts).toHaveLength(1); + expect(message.uiArtifacts?.[0].payload).toMatchObject({ id: 'form-1', title: '新增学生' }); }); - it('merges ui.artifact events into uiArtifacts and legacy lists by id', () => { + it('merges ui.artifact events into uiArtifacts by id', () => { let message = reduceAiSseMessage(undefined, { event: 'ui.artifact', data: JSON.stringify({ @@ -185,8 +182,8 @@ describe('AI chat SSE message reducer', () => { }); expect(message.uiArtifacts).toHaveLength(2); - expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' }); - expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'expired' }); + expect(message.uiArtifacts?.[0].payload).toMatchObject({ id: 'form-1', title: '新增学生' }); + expect(message.uiArtifacts?.[1].payload).toMatchObject({ id: 'review-1', status: 'expired' }); }); @@ -213,7 +210,7 @@ describe('AI chat SSE message reducer', () => { expect(message.forms?.[0].id).toBe('form-9'); }); - it('merges ui.review events into the assistant message and updates by id', () => { + it('ignores legacy ui.review events and only consumes ui.artifact', () => { const review = { id: 'review-1', title: '开学导入', @@ -246,8 +243,9 @@ describe('AI chat SSE message reducer', () => { }), }); - expect(message.reviews).toHaveLength(1); - expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'submitted' }); + // 旧事件不再写入 legacy 列表 + expect(message.reviews).toBeUndefined(); + expect(message.uiArtifacts).toHaveLength(0); }); it('shows model retrying state and clears it when content starts', () => { @@ -301,7 +299,7 @@ describe('AI chat SSE message reducer', () => { expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' }); }); - it('merges ui.chart events into the assistant message by id', () => { + it('ignores legacy ui.chart events and only consumes ui.artifact', () => { const chart = { id: 'chart-1', title: '各班级人数', @@ -327,9 +325,9 @@ describe('AI chat SSE message reducer', () => { }), }); - expect(message.charts).toHaveLength(2); - expect(message.charts?.[0]).toMatchObject({ id: 'chart-1', chartType: 'bar' }); - expect(message.charts?.[1]).toMatchObject({ id: 'chart-2' }); + // 旧事件不再写入 legacy 列表 + expect(message.charts).toBeUndefined(); + expect(message.uiArtifacts).toHaveLength(0); }); it('restores persisted charts from message.completed metadata', () => { diff --git a/apps/admin/src/components/AiChat/sseReducer.ts b/apps/admin/src/components/AiChat/sseReducer.ts index e58a224d..69ed7243 100644 --- a/apps/admin/src/components/AiChat/sseReducer.ts +++ b/apps/admin/src/components/AiChat/sseReducer.ts @@ -9,7 +9,7 @@ import type { AiSseChunk, AiToolRun, } from './types'; -import { mergeArtifactIntoMessage, mergeById, mergeForms } from './uiArtifacts'; +import { mergeArtifactIntoMessage, mergeById } from './uiArtifacts'; export interface AiSsePayload { messageId?: number; @@ -112,7 +112,9 @@ function applyMessagePayload( payload: AiSsePayload, ): void { if (typeof nested !== 'object' || nested === null) return; - message.forms = mergeForms( + // 历史消息兼容:老数据只有 metadata.a2uiForm/a2uiReview/a2uiChart, + // 恢复为 legacy 字段供渲染层在 uiArtifacts 为空时回退使用。 + message.forms = mergeById( message.forms, (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, ); @@ -159,13 +161,9 @@ export function reduceAiSseMessage( message.content += payload.delta ?? payload.content ?? ''; } else if (event === 'model.retrying' && payload.retry) { message.retrying = payload.retry; - } else if (event === 'ui.form' && payload.form) { - message.forms = mergeForms(message.forms, payload.form); - } else if (event === 'ui.review' && payload.review) { - message.reviews = mergeById(message.reviews, payload.review); - } else if (event === 'ui.chart' && payload.chart) { - message.charts = mergeById(message.charts, payload.chart); } else if (event === 'ui.artifact' && payload.artifact) { + // 统一 artifact 事件:后端过渡期仍双发 ui.form/ui.review/ui.chart, + // 前端只消费 ui.artifact,legacy 列表由渲染层从 uiArtifacts 派生。 mergeArtifactIntoMessage(message, payload.artifact); } else if (event === 'ui.import_wizard' && payload.wizard) { message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index aee08233..85d915ce 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -171,9 +171,13 @@ export interface AiChatMessage { reasoningContent: string; toolRuns: AiToolRun[]; attachments: AiAttachment[]; + /** @deprecated 仅历史消息兼容读取(metadata.a2uiForm);新数据统一走 uiArtifacts */ forms?: AiFormSchema[]; + /** @deprecated 仅历史消息兼容读取(metadata.a2uiReview);新数据统一走 uiArtifacts */ reviews?: AiReviewSchema[]; + /** @deprecated 仅历史消息兼容读取(metadata.a2uiChart);新数据统一走 uiArtifacts */ charts?: AiChartSchema[]; + /** 统一 A2UI 制品协议(唯一事实源) */ uiArtifacts?: AiArtifactSchema[]; replyToMessageId?: number | null; metadata?: Record | null; diff --git a/apps/admin/src/components/AiChat/uiArtifacts.ts b/apps/admin/src/components/AiChat/uiArtifacts.ts index 32f1f59f..6b90bc5c 100644 --- a/apps/admin/src/components/AiChat/uiArtifacts.ts +++ b/apps/admin/src/components/AiChat/uiArtifacts.ts @@ -25,43 +25,49 @@ export function mergeById( return next; } -export function mergeForms( - current: AiFormSchema[] | undefined, - incoming: AiFormSchema | AiFormSchema[] | undefined, -): AiFormSchema[] { - const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; - if (!items.length) return current ?? []; - const next = [...(current ?? [])]; - for (const item of items) { - if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) { - next.push(item); - } - } - return next; -} - function payloadOf(artifact: AiArtifactSchema): unknown { return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {}; } /** - * 将统一 artifact 归入 uiArtifacts,并按类型派发到 legacy 列表。 - * payload 来自服务端契约(表单/审阅/图表/预检/向导),按类型做单次断言。 + * 将统一 artifact 归入 uiArtifacts。 + * + * 注意:不再派发到 legacy 列表(forms/reviews/charts)——渲染层从 + * uiArtifacts 派生,legacy 字段仅保留给历史消息(metadata 中只有 + * a2uiForm/a2uiReview/a2uiChart 的老数据)作兼容读取。 */ export function mergeArtifactIntoMessage( message: AiChatMessage, artifact: AiArtifactSchema, ): AiChatMessage { message.uiArtifacts = mergeById(message.uiArtifacts, artifact); - const payload = payloadOf(artifact); - if (artifact.type === 'form') { - message.forms = mergeForms(message.forms, payload as AiFormSchema); - } else if (artifact.type === 'review') { - message.reviews = mergeById(message.reviews, payload as AiReviewSchema); - } else if (artifact.type === 'chart') { - message.charts = mergeById(message.charts, payload as AiChartSchema); - } else if (artifact.type === 'import_wizard') { - message.metadata = { ...message.metadata, a2uiImportWizard: payload }; - } + void payloadOf(artifact); return message; } + +/** + * 从 uiArtifacts 派生 legacy 列表(渲染用)。 + * 仅当 message 上没有显式 legacy 数据(历史消息)时,渲染层回退到 message.forms 等。 + */ +export function deriveForms(message: AiChatMessage): AiFormSchema[] { + return (message.uiArtifacts ?? []) + .filter((artifact) => artifact.type === 'form') + .map((artifact) => artifact.payload) + .filter((payload): payload is AiFormSchema => Boolean(payload) && typeof payload === 'object'); +} + +export function deriveReviews(message: AiChatMessage): AiReviewSchema[] { + return (message.uiArtifacts ?? []) + .filter((artifact) => artifact.type === 'review') + .map((artifact) => artifact.payload) + .filter( + (payload): payload is AiReviewSchema => Boolean(payload) && typeof payload === 'object', + ); +} + +export function deriveCharts(message: AiChatMessage): AiChartSchema[] { + return (message.uiArtifacts ?? []) + .filter((artifact) => artifact.type === 'chart') + .map((artifact) => artifact.payload) + .filter((payload): payload is AiChartSchema => Boolean(payload) && typeof payload === 'object'); +} diff --git a/scripts/a2ui-contract.md b/scripts/a2ui-contract.md index ef3b335d..ad0beaee 100644 --- a/scripts/a2ui-contract.md +++ b/scripts/a2ui-contract.md @@ -148,14 +148,15 @@ interface AiUiForm { - **统一类型**:`types.ts` 以 `AiArtifactSchema`(`message.uiArtifacts`)为唯一事实源, `artifact.type` ∈ `form | review | chart | import_wizard`,`payload` 携带各类型 schema。 -- **合并逻辑**:`uiArtifacts.ts` 的 `mergeArtifactIntoMessage` 将新 artifact upsert 进 - `uiArtifacts`,并**向后兼容**地派发到 legacy 字段(`forms`/`reviews`/`charts`)供老消息渲染。 -- **渲染层**:`AiMessageContent.tsx` 消费 legacy 列表渲染 `DynamicForm`/`DynamicReview`/ - `DynamicChart`;每个制品用 `ArtifactErrorBoundary` 包裹(单个渲染失败不影响整条气泡)。 +- **合并逻辑**:`uiArtifacts.ts` 的 `mergeArtifactIntoMessage` 只维护 `uiArtifacts` + (不再派发 legacy);渲染层经 `deriveForms/deriveReviews/deriveCharts` 从 artifact 派生。 +- **SSE 事件**:后端过渡期仍双发 `ui.form/ui.review/ui.chart` 与 `ui.artifact`, + 前端**只消费 `ui.artifact`**,旧事件分支已删除(后端后续可移除旧发射)。 +- **历史兼容**:老消息 metadata 中只有 `a2uiForm/a2uiReview/a2uiChart`(无 uiArtifacts)时, + `sseReducer`/`message-mappers` 恢复为 legacy 字段(`message.forms/reviews/charts`, + 已在 `types.ts` 标注 deprecated),渲染层在 uiArtifacts 为空时回退使用; + 新数据一律只写 `uiArtifacts`。 - **A2UI 组件实现**:`DynamicForm`/`DynamicReview`/`DynamicChart` 共享 `useSubmissionState`(提交状态:防重复提交 + 失败可重试)与 `useXCardSurface` (XCard commands 增量更新 + createSurface 自动去重)。 -- **SSE 事件 / 提交与确认接口**:与上文契约一致,未变更。 - -后续若彻底移除 legacy 字段,需保证历史消息(持久化 metadata 中的 `forms`/`reviews`/`charts`) -仍可渲染——建议先迁移历史数据或保留兼容读取。 +- **提交与确认接口**:与上文契约一致,未变更。 From 4b60e0c01825e397a2b5ed46757997de03878711 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 09:43:48 +0800 Subject: [PATCH 11/43] =?UTF-8?q?fix:=20code-review=20=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E4=BF=AE=E5=A4=8D=20+=20A2UI=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standards 轴: - 移除 uiArtifacts.ts 的 payloadOf 死代码残留 - AttendanceDevices 残留 any 类型化(补 ClassroomOption.status 字段) - 批量考勤纠错区分业务失败(已结算/无权限)与系统错误, 前端提示精确到两类数量 - Dashboard queryFn 六段重复校验块收敛为 safeValidate 助手 Spec 轴: - 补齐阶段 3.4 A2UI 测试:图表空数据占位、ArtifactErrorBoundary 降级隔离、useSubmissionState/useXCardSurface 单测(7 用例) - 阶段 2.2 补两处引导:教师工作台区分「今日无课」与「未分配班级」、 班级花名册空态带「添加学员」动作 - 契约文档修正 DynamicReview 状态管理描述(多提交点如实说明) aislop 剩余 16 警告均为必要豁免(类型边界/声明式 SQL 配置/既有文件规模) --- .../AiChat/bubble.integration.test.tsx | 46 +++++ .../src/components/AiChat/uiArtifacts.ts | 5 - .../AiChat/useSubmissionState.test.tsx | 161 ++++++++++++++++++ .../Attendance/LessonAttendanceDetail.tsx | 18 +- apps/admin/src/pages/Attendance/admin.tsx | 18 +- apps/admin/src/pages/AttendanceDevices.tsx | 3 +- .../src/pages/Classes/ClassDetailTabs.tsx | 14 ++ apps/admin/src/pages/Dashboard/index.tsx | 91 ++++------ .../src/pages/TeacherWorkspace/index.tsx | 10 +- .../agent-context/pending-tasks.service.ts | 23 ++- .../attendance-records.controller.ts | 18 +- scripts/a2ui-contract.md | 7 +- 12 files changed, 323 insertions(+), 91 deletions(-) create mode 100644 apps/admin/src/components/AiChat/useSubmissionState.test.tsx diff --git a/apps/admin/src/components/AiChat/bubble.integration.test.tsx b/apps/admin/src/components/AiChat/bubble.integration.test.tsx index 761bc732..7123e647 100644 --- a/apps/admin/src/components/AiChat/bubble.integration.test.tsx +++ b/apps/admin/src/components/AiChat/bubble.integration.test.tsx @@ -4,6 +4,7 @@ import { Bubble } from '@ant-design/x'; import { afterEach, describe, expect, it } from 'vitest'; import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer'; import { AiMessageContent } from './AiMessageContent'; +import { ArtifactErrorBoundary } from './ArtifactErrorBoundary'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; @@ -504,4 +505,49 @@ describe('AI chat bubble rendering', () => { expect(container.textContent).toContain(label); expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull(); }); + + it('renders an empty-data placeholder instead of a blank chart', async () => { + const chart: AiChartSchema = { + id: 'chart-empty', + title: '空图表', + chartType: 'bar', + columns: [{ key: 'name', title: '名称' }], + rows: [], + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + expect(container.textContent).toContain('空图表'); + expect(container.textContent).toContain('暂无数据'); + expect(container.querySelector('canvas')).toBeNull(); + }); + + it('degrades a single failing artifact to an error card without crashing the bubble', async () => { + const Bomb: React.FC = () => { + throw new Error('boom'); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + // 用无错误边界的兄弟节点 + 错误边界内的炸弹组件验证隔离 + await act(async () => { + root?.render( +
+
正常内容
+ + + +
, + ); + }); + + expect(container.querySelector('.neighbor')?.textContent).toContain('正常内容'); + expect(container.textContent).toContain('表单渲染失败'); + }); }); diff --git a/apps/admin/src/components/AiChat/uiArtifacts.ts b/apps/admin/src/components/AiChat/uiArtifacts.ts index 6b90bc5c..1e9b3023 100644 --- a/apps/admin/src/components/AiChat/uiArtifacts.ts +++ b/apps/admin/src/components/AiChat/uiArtifacts.ts @@ -25,10 +25,6 @@ export function mergeById( return next; } -function payloadOf(artifact: AiArtifactSchema): unknown { - return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {}; -} - /** * 将统一 artifact 归入 uiArtifacts。 * @@ -41,7 +37,6 @@ export function mergeArtifactIntoMessage( artifact: AiArtifactSchema, ): AiChatMessage { message.uiArtifacts = mergeById(message.uiArtifacts, artifact); - void payloadOf(artifact); return message; } diff --git a/apps/admin/src/components/AiChat/useSubmissionState.test.tsx b/apps/admin/src/components/AiChat/useSubmissionState.test.tsx new file mode 100644 index 00000000..4fc8a422 --- /dev/null +++ b/apps/admin/src/components/AiChat/useSubmissionState.test.tsx @@ -0,0 +1,161 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useSubmissionState, useXCardSurface } from './useSubmissionState'; + +// 项目未安装 @testing-library/react,用 createRoot + harness 组件暴露 hook API +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; +let api: ReturnType | null = null; +let surface: ReturnType | null = null; +let surfaceId = 'surface-test'; + +function Harness() { + api = useSubmissionState(); + surface = useXCardSurface(surfaceId); + return null; +} + +function renderHarness(): void { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render(); + }); +} + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; + api = null; + surface = null; + surfaceId = 'surface-test'; +}); + +describe('useSubmissionState', () => { + it('tracks submitting during the task and succeeds afterwards', async () => { + renderHarness(); + let resolveTask: () => void = () => undefined; + const task = () => + new Promise((resolve) => { + resolveTask = resolve; + }); + + let promise: Promise | undefined; + act(() => { + promise = api?.run(task); + }); + expect(api?.submitting).toBe(true); + expect(api?.error).toBeNull(); + + await act(async () => { + resolveTask(); + await promise; + }); + expect(api?.submitting).toBe(false); + expect(api?.submitted).toBe(true); + expect(api?.error).toBeNull(); + }); + + it('captures the error message and keeps submitted false on failure', async () => { + renderHarness(); + const failing = () => { + throw new Error('接口 500'); + }; + + await act(async () => { + await api?.run(failing); + }); + expect(api?.submitting).toBe(false); + expect(api?.submitted).toBe(false); + expect(api?.error).toBe('接口 500'); + }); + + it('normalizes non-Error rejections to a generic message', async () => { + renderHarness(); + const failing = () => Promise.reject('raw string'); + + await act(async () => { + await api?.run(failing); + }); + expect(api?.error).toBe('提交失败,请稍后重试'); + }); + + it('ignores re-entrant calls while a task is in flight', async () => { + renderHarness(); + let resolveTask: () => void = () => undefined; + const task = () => + new Promise((resolve) => { + resolveTask = resolve; + }); + let secondRan = false; + + act(() => { + void api?.run(task); + void api?.run(() => { + secondRan = true; + }); + }); + expect(secondRan).toBe(false); + + await act(async () => { + resolveTask(); + }); + expect(api?.submitted).toBe(true); + }); + + it('reset clears submitted and error states', async () => { + renderHarness(); + await act(async () => { + await api?.run(() => undefined); + }); + expect(api?.submitted).toBe(true); + + act(() => { + api?.reset(); + }); + expect(api?.submitted).toBe(false); + expect(api?.error).toBeNull(); + }); +}); + +describe('useXCardSurface', () => { + it('deduplicates createSurface commands for the same surface id', () => { + renderHarness(); + act(() => { + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } }, + { version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 1 } }, + ]); + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } }, + { version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 2 } }, + ]); + }); + const createCommands = surface?.commands.filter((command) => 'createSurface' in command); + expect(createCommands).toHaveLength(1); + expect(surface?.commands).toHaveLength(3); + }); + + it('resets the command stream when the surface id changes', () => { + renderHarness(); + act(() => { + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'c' } }, + ]); + }); + expect(surface?.commands).toHaveLength(1); + + surfaceId = 'surface-other'; + act(() => { + root?.render(); + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-other', catalogId: 'c' } }, + ]); + }); + expect(surface?.commands).toHaveLength(1); + }); +}); diff --git a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx index c6e64b8f..b11e718e 100644 --- a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx +++ b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx @@ -116,10 +116,12 @@ const LessonAttendanceDetail: React.FC = ({ onOk: async () => { setBatchUpdating(status); try { - const res = await api.put<{ updated: number; failed: number; failedIds: number[] }>( - '/attendance-records/batch-status', - { ids: targetIds, status }, - ); + const res = await api.put<{ + updated: number; + failed: number; + failedIds: number[]; + systemFailed: number; + }>('/attendance-records/batch-status', { ids: targetIds, status }); if (cancelledRef.current) return; const failedSet = new Set(res.failedIds); setRecords((items) => @@ -130,7 +132,13 @@ const LessonAttendanceDetail: React.FC = ({ ), ); message.success(`已更新 ${res.updated} 条记录`); - if (res.failed > 0) message.warning(`有 ${res.failed} 条更新失败(可能已结算)`); + if (res.failed > 0) { + const bizFailed = res.failed - (res.systemFailed ?? 0); + const parts: string[] = []; + if (bizFailed > 0) parts.push(`${bizFailed} 条可能已结算`); + if (res.systemFailed > 0) parts.push(`${res.systemFailed} 条系统错误`); + message.warning(`有 ${res.failed} 条更新失败:${parts.join(',')}`); + } } catch (error: unknown) { if (cancelledRef.current) return; message.error(getErrorMessage(error, '批量更新失败')); diff --git a/apps/admin/src/pages/Attendance/admin.tsx b/apps/admin/src/pages/Attendance/admin.tsx index 610062a6..1b9522eb 100644 --- a/apps/admin/src/pages/Attendance/admin.tsx +++ b/apps/admin/src/pages/Attendance/admin.tsx @@ -422,13 +422,21 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit onOk: async () => { setBatchCorrecting(true); try { - const res = await api.put<{ updated: number; failed: number; failedIds: number[] }>( - '/attendance-records/batch-status', - { ids: selectedRecordIds, status: nextStatus }, - ); + const res = await api.put<{ + updated: number; + failed: number; + failedIds: number[]; + systemFailed: number; + }>('/attendance-records/batch-status', { ids: selectedRecordIds, status: nextStatus }); setSelectedRecordIds([]); message.success(`已更新 ${res.updated} 条记录`); - if (res.failed > 0) message.warning(`有 ${res.failed} 条更新失败(可能已结算)`); + if (res.failed > 0) { + const bizFailed = res.failed - (res.systemFailed ?? 0); + const parts: string[] = []; + if (bizFailed > 0) parts.push(`${bizFailed} 条可能已结算`); + if (res.systemFailed > 0) parts.push(`${res.systemFailed} 条系统错误`); + message.warning(`有 ${res.failed} 条更新失败:${parts.join(',')}`); + } void refetchRecords(); } catch (e: unknown) { message.error(getErrorMessage(e, '批量更新失败')); diff --git a/apps/admin/src/pages/AttendanceDevices.tsx b/apps/admin/src/pages/AttendanceDevices.tsx index f75d369e..36189609 100644 --- a/apps/admin/src/pages/AttendanceDevices.tsx +++ b/apps/admin/src/pages/AttendanceDevices.tsx @@ -18,6 +18,7 @@ interface ClassroomOption { id: number; name: string; building?: string | null; + status?: string; } interface AttendanceDeviceRow { @@ -63,7 +64,7 @@ const AttendanceDevicesPage: React.FC = () => { classrooms: validateResponse( classroomOptionsSchema, classroomList, - ).filter((item: any) => item.status !== 'archived'), + ).filter((item: ClassroomOption) => item.status !== 'archived'), }; }, }); diff --git a/apps/admin/src/pages/Classes/ClassDetailTabs.tsx b/apps/admin/src/pages/Classes/ClassDetailTabs.tsx index 155978d2..03e9c452 100644 --- a/apps/admin/src/pages/Classes/ClassDetailTabs.tsx +++ b/apps/admin/src/pages/Classes/ClassDetailTabs.tsx @@ -22,6 +22,7 @@ import { DownloadOutlined, PlusOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import { useUserStore } from '../../store/user/userStore'; import PermissionButton from '../../components/PermissionButton'; +import { QueryEmpty } from '../../components/QueryState'; import { message } from '../../ui/app-message'; import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate'; @@ -324,6 +325,19 @@ export const ClassStudentsTab: React.FC<{ columns={studentColumns} dataSource={students} rowKey="id" + locale={{ + emptyText: ( + , + onClick: onOpen, + }} + /> + ), + }} pagination={{ defaultPageSize: 20, showSizeChanger: true, diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index 1fb2467b..143f2065 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -105,72 +105,41 @@ const DashboardPage: React.FC = () => { console.error('部分看板数据加载失败', rejected); message.warning(`有 ${rejected.length} 项数据加载失败,其余数据已正常显示`); } - let stats: DashboardStats | null = null; - const s = value(settled[0]); - if (s) { + // 校验失败的模块降级为对应空值,不影响其他模块 + type ValidateSchema = Parameters[0]; + const safeValidate = (schema: ValidateSchema, raw: unknown, fallback: T): T => { try { - stats = validateResponse(dashboardStatsSchema, s); + return validateResponse(schema, raw); } catch (e) { console.error(e); + return fallback; } - } - let roomRanking: Array<{ roomNumber: string; total: string }> = []; - const rr = value(settled[1]); - if (rr) { - try { - roomRanking = validateResponse>( - roomRankingSchema, - rr, - ); - } catch (e) { - console.error(e); - } - } - let classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] } = { - top: [], - bottom: [], }; - const cr = value(settled[2]); - if (cr) { - try { - classRanking = validateResponse<{ - top: ClassAttendanceRank[]; - bottom: ClassAttendanceRank[]; - }>(classAttendanceRankingSchema, cr); - } catch (e) { - console.error(e); - } - } - let ganttData: GanttRoom[] = []; - const g = value(settled[3]); - if (g) { - try { - ganttData = validateResponse(ganttRoomsSchema, g); - } catch (e) { - console.error(e); - } - } - let classroomOccupancy: ClassroomOccupancy[] = []; - const co = value(settled[4]); - if (co) { - try { - classroomOccupancy = validateResponse( - classroomOccupanciesSchema, - co, - ); - } catch (e) { - console.error(e); - } - } - let classroomUtil: ClassroomUtilStats | null = null; - const cu = value(settled[5]); - if (cu) { - try { - classroomUtil = validateResponse(classroomUtilStatsSchema, cu); - } catch (e) { - console.error(e); - } - } + const stats = safeValidate( + dashboardStatsSchema, + value(settled[0]), + null, + ); + const roomRanking = safeValidate>( + roomRankingSchema, + value(settled[1]), + [], + ); + const classRanking = safeValidate<{ + top: ClassAttendanceRank[]; + bottom: ClassAttendanceRank[]; + }>(classAttendanceRankingSchema, value(settled[2]), { top: [], bottom: [] }); + const ganttData = safeValidate(ganttRoomsSchema, value(settled[3]), []); + const classroomOccupancy = safeValidate( + classroomOccupanciesSchema, + value(settled[4]), + [], + ); + const classroomUtil = safeValidate( + classroomUtilStatsSchema, + value(settled[5]), + null, + ); return { stats, classRanking, classroomOccupancy, ganttData, roomRanking, classroomUtil }; }, }); diff --git a/apps/admin/src/pages/TeacherWorkspace/index.tsx b/apps/admin/src/pages/TeacherWorkspace/index.tsx index 0995269d..abd418c9 100644 --- a/apps/admin/src/pages/TeacherWorkspace/index.tsx +++ b/apps/admin/src/pages/TeacherWorkspace/index.tsx @@ -6,7 +6,7 @@ import { teacherWorkspaceSchema } from '../../api/schemas'; import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import api from '../../api'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; interface AssignedClass { classId: number; @@ -189,7 +189,13 @@ const TeacherWorkspacePage: React.FC = () => { }} /> ) : ( - + ), }, diff --git a/apps/server/src/agent-context/pending-tasks.service.ts b/apps/server/src/agent-context/pending-tasks.service.ts index 17a78ad8..b49f9107 100644 --- a/apps/server/src/agent-context/pending-tasks.service.ts +++ b/apps/server/src/agent-context/pending-tasks.service.ts @@ -29,6 +29,19 @@ function can(context: AgentToolContext, permission: string): boolean { return context.isSuperAdmin || context.permissions.includes(permission); } +/** 教师作用域:仅统计该教师任课班级的数据 */ +function teacherScoped(scope: StudentAccessScope): boolean { + return scope.type === 'teacher'; +} + +/** 教师作用域下的班级过滤 SQL 片段(需配合 class_student cs 别名) */ +const TEACHER_CLASS_FILTER_SQL = + 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)'; + +function teacherParams(scope: StudentAccessScope): unknown[] { + return scope.type === 'teacher' ? [scope.userId] : []; +} + function today(): string { const now = new Date(); const year = now.getFullYear(); @@ -78,10 +91,10 @@ const TASKS: readonly TaskDefinition[] = [ INNER JOIN class_student cs ON cs.student_id = s.id AND cs.status = 'active' LEFT JOIN occupancies o ON o.student_id = s.id AND o.status = 'active' WHERE s.status = 'active' - ${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''} + ${teacherScoped(scope) ? TEACHER_CLASS_FILTER_SQL : ''} AND o.id IS NULL `, - params: scope.type === 'teacher' ? [scope.userId] : [], + params: teacherParams(scope), }), }, { @@ -93,13 +106,13 @@ const TASKS: readonly TaskDefinition[] = [ sql: (scope) => ({ sql: ` SELECT COUNT(DISTINCT o.id) AS cnt FROM occupancies o - ${scope.type === 'teacher' ? 'INNER JOIN class_student cs ON cs.student_id = o.student_id AND cs.status = \'active\'' : ''} + ${teacherScoped(scope) ? "INNER JOIN class_student cs ON cs.student_id = o.student_id AND cs.status = 'active'" : ''} LEFT JOIN bills b ON b.student_id = o.student_id WHERE o.status = 'active' - ${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''} + ${teacherScoped(scope) ? TEACHER_CLASS_FILTER_SQL : ''} AND b.id IS NULL `, - params: scope.type === 'teacher' ? [scope.userId] : [], + params: teacherParams(scope), }), }, { diff --git a/apps/server/src/attendance/attendance-records.controller.ts b/apps/server/src/attendance/attendance-records.controller.ts index c8c9d6ba..15493278 100644 --- a/apps/server/src/attendance/attendance-records.controller.ts +++ b/apps/server/src/attendance/attendance-records.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, Query, Request, Res, BadRequestException, ForbiddenException, ParseIntPipe } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, Query, Request, Res, BadRequestException, ForbiddenException, NotFoundException, ParseIntPipe } from '@nestjs/common'; import type { Response } from 'express'; import { AttendanceControllerBase, RequestUser } from './attendance.controller-base'; import { AttendanceService } from './attendance.service'; @@ -246,6 +246,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { ) { const failedIds: number[] = []; let updated = 0; + let systemFailed = 0; for (const id of dto.ids) { try { const existing = await this.service.findAttendanceRecord(id); @@ -255,15 +256,24 @@ export class AttendanceRecordsController extends AttendanceControllerBase { if (existing.classId != null) await this.assertClassAccess(req, existing.classId); await this.service.update(id, { status: dto.status, remark: dto.remark }); updated += 1; - } catch { + } catch (error) { failedIds.push(id); + // 业务失败(已结算/无权限等)与系统错误区分开,便于前端给出准确提示 + if ( + error instanceof BadRequestException || + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + continue; + } + systemFailed += 1; } } await logAudit(this.logService, req, { module: '考勤管理', action: '批量修改考勤状态', targetId: 0, targetType: 'attendanceRecord', - detail: `批量 ${dto.ids.length} 条 → ${dto.status},成功 ${updated},失败 ${failedIds.length}`, + detail: `批量 ${dto.ids.length} 条 → ${dto.status},成功 ${updated},失败 ${failedIds.length},系统错误 ${systemFailed}`, }); - return { updated, failed: failedIds.length, failedIds }; + return { updated, failed: failedIds.length, failedIds, systemFailed }; } // ── Update a single attendance record ── diff --git a/scripts/a2ui-contract.md b/scripts/a2ui-contract.md index ad0beaee..ba2067dd 100644 --- a/scripts/a2ui-contract.md +++ b/scripts/a2ui-contract.md @@ -156,7 +156,8 @@ interface AiUiForm { `sseReducer`/`message-mappers` 恢复为 legacy 字段(`message.forms/reviews/charts`, 已在 `types.ts` 标注 deprecated),渲染层在 uiArtifacts 为空时回退使用; 新数据一律只写 `uiArtifacts`。 -- **A2UI 组件实现**:`DynamicForm`/`DynamicReview`/`DynamicChart` 共享 - `useSubmissionState`(提交状态:防重复提交 + 失败可重试)与 `useXCardSurface` - (XCard commands 增量更新 + createSurface 自动去重)。 +- **A2UI 组件实现**:`DynamicForm`/`DynamicChart` 使用 `useSubmissionState` + (单提交点:防重复提交 + 失败可重试)与 `useXCardSurface`(XCard commands + 增量更新 + createSurface 自动去重);`DynamicReview` 因「逐表确认/逐组确认/全部入库」 + 多提交点并存,保留组件内多提交状态,仅共享 `useXCardSurface`。 - **提交与确认接口**:与上文契约一致,未变更。 From 1e3d6ee20f589a4cc1020223eab3bae7ddf9269f Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 09:48:03 +0800 Subject: [PATCH 12/43] =?UTF-8?q?refactor(server):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E8=BE=B9=E7=95=8C=E5=B7=A5=E5=85=B7,aislop?= =?UTF-8?q?=20AI=20Slop=2010=20=E2=86=92=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 common/buffer.ts bufferToArrayBuffer:9 处 'as unknown as ArrayBuffer' 收敛为精确切片(含 byteOffset), 消除潜在 Buffer 池偏移隐患,类型断言集中到单一实现 - 新增 common/stringify.ts:4 处重复的 stringify 助手收敛为共享实现 - aislop: AI Slop 10→1(仅剩 1 处有理由的 stringify 薄包装, eslint no-base-to-string 绕过所需);Code Quality 剩余 4 重复块(声明式 SQL 配置)+ 2 文件过大(既有规模)均保留 - 测试 142 套件/1065 用例通过 --- .../agent-tools/tools/create-student.tool.ts | 6 +----- .../src/ai-chat/ai-excel-reader.service.ts | 3 ++- .../src/classrooms/classrooms.controller.ts | 3 ++- apps/server/src/common/buffer.ts | 17 +++++++++++++++++ apps/server/src/common/stringify.ts | 13 +++++++++++++ .../database/database-migrations.backfill.ts | 6 +----- apps/server/src/expenses/expenses.controller.ts | 5 +++-- apps/server/src/imports/imports.workbook.ts | 3 ++- .../config/integration-config.service.ts | 9 +++------ .../src/occupancies/occupancies.controller.ts | 3 ++- apps/server/src/rooms/rooms.controller.ts | 3 ++- .../src/schedules/schedule-queries.service.ts | 6 +----- apps/server/src/students/students.controller.ts | 5 +++-- 13 files changed, 52 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/common/buffer.ts create mode 100644 apps/server/src/common/stringify.ts diff --git a/apps/server/src/agent-tools/tools/create-student.tool.ts b/apps/server/src/agent-tools/tools/create-student.tool.ts index 6b48716d..7bfdb6e1 100644 --- a/apps/server/src/agent-tools/tools/create-student.tool.ts +++ b/apps/server/src/agent-tools/tools/create-student.tool.ts @@ -3,6 +3,7 @@ import { DataSource } from 'typeorm'; import { Organization } from '../../entities/organization.entity'; import { StudentsService } from '../../students/students.service'; import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types'; +import { stringify } from '../../common/stringify'; /** Whitelisted input shape for create_student. */ interface CreateStudentInput { @@ -28,11 +29,6 @@ const FORBIDDEN_INPUT_KEYS = new Set([ const PHONE_RE = /^1[3-9]\d{9}$/; -/** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */ -function stringify(value: unknown): string { - return String(value); -} - /** * Creates a student archive from form-confirmed data. * diff --git a/apps/server/src/ai-chat/ai-excel-reader.service.ts b/apps/server/src/ai-chat/ai-excel-reader.service.ts index 83d1d98a..7bd996a8 100644 --- a/apps/server/src/ai-chat/ai-excel-reader.service.ts +++ b/apps/server/src/ai-chat/ai-excel-reader.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import ExcelJS from 'exceljs'; import { readXlsxSheetsFallback } from '../imports/imports.workbook-fallback'; +import { bufferToArrayBuffer } from '../common/buffer'; export interface ExcelSheetInfo { name: string; @@ -86,7 +87,7 @@ export class AiExcelReaderService { private async loadWithExcelJs(buffer: Buffer): Promise { const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(buffer)); const sheets: ExcelSheetRows[] = []; workbook.eachSheet((sheet) => { const rows: string[][] = []; diff --git a/apps/server/src/classrooms/classrooms.controller.ts b/apps/server/src/classrooms/classrooms.controller.ts index 347b8b74..3dbfa05a 100644 --- a/apps/server/src/classrooms/classrooms.controller.ts +++ b/apps/server/src/classrooms/classrooms.controller.ts @@ -24,6 +24,7 @@ import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import * as ExcelJS from 'exceljs'; import { CLASSROOM_TEMPLATE_COLUMNS } from './classroom-template'; +import { bufferToArrayBuffer } from '../common/buffer'; interface AuthenticatedRequest { user?: { id: number; username: string }; @@ -173,7 +174,7 @@ export class ClassroomsController { async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; const rows: { name: string; diff --git a/apps/server/src/common/buffer.ts b/apps/server/src/common/buffer.ts new file mode 100644 index 00000000..c731caf5 --- /dev/null +++ b/apps/server/src/common/buffer.ts @@ -0,0 +1,17 @@ +/** + * 将 Node Buffer 安全转换为 ArrayBuffer。 + * + * ExcelJS 的 xlsx.load 类型只接受 Buffer | ArrayBuffer,而 Node 20+ 的 + * Buffer 已是泛型 Buffer,与 exceljs 的旧类型签名不兼容, + * 直接传参会报 TS2345。此处取底层 ArrayBuffer 的精确切片 + * (含 byteOffset/byteLength),既避免 Buffer 来自池切片时携带无关字节, + * 也把类型边界收敛到单一实现。 + */ +export function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ) as ArrayBuffer; +} + +export default bufferToArrayBuffer; diff --git a/apps/server/src/common/stringify.ts b/apps/server/src/common/stringify.ts new file mode 100644 index 00000000..6ec21c6c --- /dev/null +++ b/apps/server/src/common/stringify.ts @@ -0,0 +1,13 @@ +/** + * 安全字符串化 unknown。 + * + * `@typescript-eslint/no-base-to-string` 规则在调用点会把 unknown 经 + * `== null` / `||` / `??` 收窄为 `{}`(对象类型)后仍标记 String(value); + * 而函数参数位置不受调用点收窄影响。此助手统一处理该场景, + * 避免各模块重复定义同名工具。 + */ +export function stringify(value: unknown): string { + return String(value); +} + +export default stringify; diff --git a/apps/server/src/database/database-migrations.backfill.ts b/apps/server/src/database/database-migrations.backfill.ts index 744d193f..078404df 100644 --- a/apps/server/src/database/database-migrations.backfill.ts +++ b/apps/server/src/database/database-migrations.backfill.ts @@ -2,17 +2,13 @@ import { Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { uuidV7 } from '../common/uuid-v7'; import { withQueryRunner } from './database-migrations.runner'; +import { stringify } from '../common/stringify'; /** 迁移脚本中用到的 organizations 表最小行结构。 */ interface OrganizationRow { id: number; } -/** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */ -function stringify(value: unknown): string { - return String(value); -} - export async function backfillOrganizations( dataSource: DataSource, ): Promise { diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 170992c0..1a9bfcee 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -37,6 +37,7 @@ import { BatchIdsDto } from '../common/batch-ids.dto'; import type { AuthenticatedUser } from '../authorization'; import { PersonalExpense } from '../entities/personal-expense.entity'; import * as ExcelJS from 'exceljs'; +import { bufferToArrayBuffer } from '../common/buffer'; interface AuthenticatedRequest { user: AuthenticatedUser; @@ -354,7 +355,7 @@ export class ExpensesController { @UseInterceptors(FileInterceptor('file')) async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; const rows: UtilityImportRow[] = []; ws.eachRow((row, idx) => { @@ -419,7 +420,7 @@ export class ExpensesController { @UseInterceptors(FileInterceptor('file')) async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; const rows: PersonalImportRow[] = []; ws.eachRow((row, idx) => { diff --git a/apps/server/src/imports/imports.workbook.ts b/apps/server/src/imports/imports.workbook.ts index e4f248e4..4df51032 100644 --- a/apps/server/src/imports/imports.workbook.ts +++ b/apps/server/src/imports/imports.workbook.ts @@ -4,6 +4,7 @@ import { Readable } from 'node:stream'; import { cellValue, textValue } from './imports.helpers'; import { fallbackSheetsToImportSheets, readXlsxSheetsFallback } from './imports.workbook-fallback'; import type { CellValue } from './imports.types'; +import { bufferToArrayBuffer } from '../common/buffer'; const MAX_SHEETS = 30; const MAX_ROWS_PER_SHEET = 3000; @@ -84,7 +85,7 @@ export async function parseSheets( if (kind === 'csv') { await workbook.csv.read(Readable.from(Buffer.from(buffer))); } else { - await workbook.xlsx.load(buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(buffer)); } const sheets = extractSheets(workbook, headerRow); if (sheets.length === 0) { diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts index 0c300d5b..ba12be88 100644 --- a/apps/server/src/integration/config/integration-config.service.ts +++ b/apps/server/src/integration/config/integration-config.service.ts @@ -1,3 +1,4 @@ +import { stringify } from '../../common/stringify'; import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -29,10 +30,6 @@ export class IntegrationConfigService { ) {} /** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */ - private stringify(value: unknown): string { - return String(value); - } - /** 解析 content JSON 并取 config 段(无 config 时回退整个对象)。 */ private parseStoredConfig(content: string): Record { const parsed = JSON.parse(content) as StoredConfigShape; @@ -208,8 +205,8 @@ export class IntegrationConfigService { ): Promise { try { if (type.toUpperCase() === 'DINGTALK') { - const appKey = this.stringify(config.agentId || ''); - const appSecret = this.stringify(config.appSecret || ''); + const appKey = stringify(config.agentId || ''); + const appSecret = stringify(config.appSecret || ''); if (!appKey || !appSecret) return null; return await this.fetchDingTalkToken(appKey, appSecret); } diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index f16f4b93..16c9f4df 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -1,3 +1,4 @@ +import { bufferToArrayBuffer } from '../common/buffer'; import { Controller, Get, @@ -270,7 +271,7 @@ export class OccupanciesController { const { ipAddress, userAgent } = extractRequestInfo(req); if (!file?.buffer) throw new BadRequestException('请上传入住名单 Excel 文件'); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; const rows = parseOccupancyImportWorksheet(ws); const result = await this.service.batchImportCheckIn(rows, { diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index dde872cd..9a4b599c 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -30,6 +30,7 @@ import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; import * as ExcelJS from 'exceljs'; +import { bufferToArrayBuffer } from '../common/buffer'; interface AuthenticatedRequest { user?: { id: number; username: string }; @@ -358,7 +359,7 @@ export class RoomsController { async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; const rows: { roomNumber: string; diff --git a/apps/server/src/schedules/schedule-queries.service.ts b/apps/server/src/schedules/schedule-queries.service.ts index c8356366..badf87f6 100644 --- a/apps/server/src/schedules/schedule-queries.service.ts +++ b/apps/server/src/schedules/schedule-queries.service.ts @@ -3,14 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ClassSchedule } from '../entities'; import type { WeeklyViewQueryDto } from './dto/schedule.dto'; +import { stringify } from '../common/stringify'; const ACTIVE_SCHEDULE_STATUS = 'active'; -/** String() 包装:避免 raw 行值(unknown 收窄为对象类型)触发 no-base-to-string。 */ -function stringify(value: unknown): string { - return String(value); -} - @Injectable() export class ScheduleQueriesService { constructor( diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 571f5b63..d84014ba 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -40,6 +40,7 @@ import { STUDENT_EXPORT_COLUMNS, } from './student-import'; import { BatchIdsDto } from '../common/batch-ids.dto'; +import { bufferToArrayBuffer } from '../common/buffer'; interface AuthenticatedRequest { user: AuthenticatedUser; @@ -255,7 +256,7 @@ export class StudentsController { @UseInterceptors(FileInterceptor('file')) async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) { @@ -280,7 +281,7 @@ export class StudentsController { @UseInterceptors(FileInterceptor('file')) async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) { From b37ae9471ecd34e2397ffc397122e7fcae040f1e Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 15:05:10 +0800 Subject: [PATCH 13/43] =?UTF-8?q?refactor(server):=20A2UI=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E2=80=94=E2=80=94=E7=A7=BB=E9=99=A4=20legacy=20SSE=20?= =?UTF-8?q?=E5=8F=8C=E5=8F=91=E4=B8=8E=20render=5Freview=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=EF=BC=8C=E6=8F=90=E5=8F=96=E5=85=AC=E5=85=B1=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/agent-context/index.ts | 12 - apps/server/src/ai-chat/ai-chart.service.ts | 22 +- apps/server/src/ai-chat/ai-chat.constants.ts | 1 + .../src/ai-chat/ai-chat.import-confirm.ts | 9 - .../src/ai-chat/ai-chat.service.spec.ts | 44 +- .../src/ai-chat/ai-chat.submissions.flow.ts | 4 - .../src/ai-chat/ai-chat.tool-actions.ts | 136 ----- apps/server/src/ai-chat/ai-chat.types.ts | 3 - apps/server/src/ai-chat/ai-form.service.ts | 27 +- apps/server/src/ai-chat/ai-review.enrich.ts | 261 --------- .../src/ai-chat/ai-review.service.spec.ts | 541 +----------------- apps/server/src/ai-chat/ai-review.service.ts | 105 +--- apps/server/src/ai-chat/ai-review.shared.ts | 229 +------- .../src/ai-chat/ai-review.validation.ts | 165 ------ apps/server/src/ai-chat/ai-review.workbook.ts | 218 +------ apps/server/src/ai-chat/ai-validation.ts | 31 + 16 files changed, 60 insertions(+), 1748 deletions(-) delete mode 100644 apps/server/src/agent-context/index.ts delete mode 100644 apps/server/src/ai-chat/ai-review.validation.ts create mode 100644 apps/server/src/ai-chat/ai-validation.ts diff --git a/apps/server/src/agent-context/index.ts b/apps/server/src/agent-context/index.ts deleted file mode 100644 index 88564aff..00000000 --- a/apps/server/src/agent-context/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { BusinessContextService } from './business-context.service'; -export { BUSINESS_ENTITIES, BUSINESS_WORKFLOWS } from './business-context.registry'; -export type { - BusinessContextPrincipal, - BusinessContextResult, - BusinessEntity, - BusinessEntityField, - BusinessEntityRelation, - BusinessWorkflow, - BusinessWorkflowNextStep, - BusinessWorkflowStage, -} from './business-context.types'; diff --git a/apps/server/src/ai-chat/ai-chart.service.ts b/apps/server/src/ai-chat/ai-chart.service.ts index 76e68c4b..bfbb112a 100644 --- a/apps/server/src/ai-chat/ai-chart.service.ts +++ b/apps/server/src/ai-chat/ai-chart.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { uuidV7 } from '../common/uuid-v7'; import type { AiReviewColumn, AiReviewRow } from './entities/ai-review.entity'; +import { assertKeys, isPlainRecord, requireString } from './ai-validation'; const MAX_TITLE = 50; const MAX_COLUMNS = 10; @@ -21,27 +22,6 @@ export interface AiChart { rows: AiReviewRow[]; } -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function requireString(value: unknown, label: string, max: number): string { - if (typeof value !== 'string' || !value.trim()) { - throw new BadRequestException(`${label}必须是字符串`); - } - const trimmed = value.trim(); - if (trimmed.length > max) { - throw new BadRequestException(`${label}长度不能超过 ${max}`); - } - return trimmed; -} - -function assertKeys(raw: Record, allowed: Set, label: string): void { - for (const key of Object.keys(raw)) { - if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`); - } -} - /** * Validates the `render_chart` tool arguments. The model sends a * whitelisted tabular shape (columns + rows); the frontend converts it diff --git a/apps/server/src/ai-chat/ai-chat.constants.ts b/apps/server/src/ai-chat/ai-chat.constants.ts index 5948a65a..04132e78 100644 --- a/apps/server/src/ai-chat/ai-chat.constants.ts +++ b/apps/server/src/ai-chat/ai-chat.constants.ts @@ -175,6 +175,7 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须 当用户需要录入或修改业务数据时,先调用 render_form 生成确认表单,提示用户填写并提交;只有在用户通过表单提交确认后,才能执行写操作工具(如 create_student、update_students)。 新增学生示例:render_form 的 fields 使用 name/phone/gender/studentNo。 修改学生示例:批量修改姓名/档案时,render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students,每条更新必须带学生 id。 +需要向用户询问信息、让用户选择或确认时(例如:确认导入策略与列映射、选择班级/房型/老师、补充必填信息、二选一确认),优先调用 render_form 生成简短的问题卡片(字段 1-4 个:选项用 select、短文本用 input、日期用 date、多行说明用 textarea),让用户直接在卡片上作答,不要用大段文字逐项提问;只有开放式讨论或无法用表单表达时才用文字提问。问题卡片标题用一句完整问题,字段 label 即选项/问题文案,必填项设置 required。 当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行: 1. 先根据消息附带的 Excel 提取文本(工作表名 + tab 分隔行)判断业务类型与表头,向用户说明将导入什么、依赖什么;需要确认的列映射、校区或策略先在聊天中与用户确认,不要替用户默认做出影响数据的决定。 2. 用户确认后调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。 diff --git a/apps/server/src/ai-chat/ai-chat.import-confirm.ts b/apps/server/src/ai-chat/ai-chat.import-confirm.ts index 55e20b89..1f8d91fe 100644 --- a/apps/server/src/ai-chat/ai-chat.import-confirm.ts +++ b/apps/server/src/ai-chat/ai-chat.import-confirm.ts @@ -87,15 +87,6 @@ export function parseConfirmedSettings(parsedRecord: Record): I return settings; } -/** 解析 resolve 端点嵌套的 settings 参数(缺省为空对象)。 */ -export function parseNestedSettings(raw: unknown): ImportRunSettings { - if (raw === undefined || raw === null) return {}; - if (typeof raw !== 'object' || Array.isArray(raw)) { - throw new BadRequestException('settings 参数格式错误'); - } - return parseConfirmedSettings(raw as Record); -} - export function isExcelAttachment(attachment: { mimeType: string; originalName: string; diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index 5312c741..81efe870 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -30,11 +30,8 @@ function createService( ...messageOverrides, }; const reviewService = { - createReview: jest.fn(), - expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), findOwned: jest.fn(), - findPendingByAssistantMessage: jest.fn(), submitSection: jest.fn(), submitGroup: jest.fn(), submitAll: jest.fn(), @@ -303,10 +300,7 @@ describe('AiChatService', () => { serialize: jest.fn((value) => value), } as never, { - createReview: jest.fn(), - expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), - findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, @@ -466,10 +460,7 @@ describe('AiChatService', () => { serialize: jest.fn((value) => value), } as never, { - createReview: jest.fn(), - expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), - findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, @@ -497,7 +488,7 @@ describe('AiChatService', () => { jest.fn(), ); - expect(emitted.some(({ event }) => event === 'ui.form')).toBe(true); + expect(emitted.some(({ event }) => event === 'ui.artifact')).toBe(true); expect(messageSave).toHaveBeenCalledWith( expect.objectContaining({ id: 12, @@ -508,7 +499,7 @@ describe('AiChatService', () => { ); }); - it('render_chart 生成的图表通过 ui.chart 推送并追加到消息 metadata', async () => { + it('render_chart 生成的图表通过 ui.artifact 推送并追加到消息 metadata', async () => { const conversation = { id: 3, userId: 7, @@ -612,10 +603,7 @@ describe('AiChatService', () => { serialize: jest.fn((value) => value), } as never, { - createReview: jest.fn(), - expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), - findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, @@ -643,7 +631,7 @@ describe('AiChatService', () => { jest.fn(), ); - expect(emitted.some(({ event }) => event === 'ui.chart')).toBe(true); + expect(emitted.some(({ event }) => event === 'ui.artifact')).toBe(true); expect(messageSave).toHaveBeenCalledWith( expect.objectContaining({ id: 12, @@ -719,10 +707,7 @@ describe('AiChatService', () => { serialize: jest.fn((value) => value), } as never, { - createReview: jest.fn(), - expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn().mockResolvedValue(review), - findPendingByAssistantMessage: jest.fn(), findOwned: jest.fn(), submitSection: jest.fn(), submitAll, @@ -757,7 +742,7 @@ describe('AiChatService', () => { expect(emitted).toHaveLength(0); }); - it('submitReview 校验写入权限、先 onReady 再推 ui.review,并标记原卡片已提交', async () => { + it('submitReview 校验写入权限、先 onReady 再推 ui.artifact,并标记原卡片已提交', async () => { const conversation = { id: 3, userId: 7, @@ -858,10 +843,7 @@ describe('AiChatService', () => { serialize: jest.fn((value) => value), } as never, { - createReview: jest.fn(), - expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn().mockResolvedValue(review), - findPendingByAssistantMessage: jest.fn(), findOwned: jest.fn(), submitSection: jest.fn(), submitAll, @@ -897,10 +879,13 @@ describe('AiChatService', () => { expect(assertPermission).toHaveBeenCalledWith(expect.anything(), 'student:create'); expect(submitAll).toHaveBeenCalledTimes(1); expect(emitted[0]).toMatchObject({ - event: 'ui.review', - data: { messageId: 12, review: expect.objectContaining({ id: 'review-1' }) }, + event: 'ui.artifact', + data: { + messageId: 12, + artifact: expect.objectContaining({ id: 'review-1', type: 'review' }), + }, }); - expect(order.indexOf('onReady')).toBeLessThan(order.indexOf('emit:ui.review')); + expect(order.indexOf('onReady')).toBeLessThan(order.indexOf('emit:ui.artifact')); expect(messageSave).toHaveBeenCalledWith( expect.objectContaining({ id: 12, @@ -1132,10 +1117,7 @@ describe('AiChatService', () => { serialize: jest.fn((value) => value), } as never, { - createReview: jest.fn(), - expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), - findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, @@ -1174,10 +1156,7 @@ describe('AiChatService', () => { serialize: jest.fn((value) => value), } as never, { - createReview: jest.fn(), - expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), - findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, @@ -1260,10 +1239,7 @@ describe('AiChatService', () => { serialize: jest.fn((value) => value), } as never, { - createReview: jest.fn(), - expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), - findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, diff --git a/apps/server/src/ai-chat/ai-chat.submissions.flow.ts b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts index a0e743f0..ac23ca5c 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.flow.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts @@ -190,10 +190,6 @@ export async function submitReview( const serialized = context.reviewService.serialize(updatedReview); onReady(); - emit('ui.review', { - messageId: updatedReview.assistantMessageId, - review: serialized, - }); emit('ui.artifact', { messageId: updatedReview.assistantMessageId, artifact: buildA2uiArtifact({ diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index 390a140f..c4795d56 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -1,4 +1,3 @@ -import { AiReview } from './entities/ai-review.entity'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import { buildA2uiArtifact } from './ai-a2ui.artifact'; import { finishToolRun, startToolRun } from './ai-chat.tools'; @@ -46,7 +45,6 @@ export async function executeRenderForm( await context.messages.save(oldAssistant); } const expiredPayload = context.formService.serialize(expired); - emit('ui.form', { messageId: expired.assistantMessageId, form: expiredPayload }); emit('ui.artifact', { messageId: expired.assistantMessageId, artifact: buildA2uiArtifact({ @@ -67,10 +65,6 @@ export async function executeRenderForm( await context.messages.save(assistant); await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成表单,等待用户填写' }, emit); - emit('ui.form', { - messageId, - form: context.formService.serialize(form), - }); emit('ui.artifact', { messageId, artifact: buildA2uiArtifact({ @@ -93,131 +87,6 @@ export async function executeRenderForm( } } -export async function executeRenderReview( - context: AiChatServiceContext, - messageId: number, - call: ModelToolCall, - userId: number, - emit: AiSseEmitter, -): Promise { - const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, { - toolName: 'render_review', - skillKey: null, - argumentsData: null, - }); - - try { - const existingReview = await context.reviewService.findPendingByAssistantMessage(messageId); - if (existingReview) { - const denial = `本回合已生成导入预览《${existingReview.title}》,请直接提示用户审阅并确认,不要再次调用 render_review;如需多个分表,应全部合并到同一张预览卡。`; - await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: denial, error: denial }, emit); - return JSON.stringify({ status: 'failed', error: denial }); - } - const assistant = await context.messages.findOne({ where: { id: messageId } }); - if (!assistant) throw new Error('assistant message missing'); - const parsedRecord = - parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) - ? (parsedArgs as Record) - : {}; - const attachmentId = - typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined; - - let review: AiReview; - if (Number.isInteger(attachmentId) && (attachmentId as number) > 0) { - const [attachment] = await context.attachmentService.requireReadyOwned(userId, [ - attachmentId as number, - ]); - if ( - !attachment.mimeType.includes('spreadsheetml') && - !attachment.mimeType.includes('excel') && - !attachment.mimeType.includes('csv') - ) { - throw new Error('附件不是 Excel 文件,无法生成导入预览'); - } - if (!context.excelReader) throw new Error('Excel 解析器未配置'); - const buffer = await context.attachmentService.readStoredBuffer(attachment); - const sheets = await context.excelReader.loadSheets(buffer); - const sections = await context.reviewService.buildSectionsFromWorkbook(sheets, parsedArgs); - review = await context.reviewService.createReview( - { userId, conversationId: assistant.conversationId, assistantMessageId: messageId }, - { title: parsedRecord.title, summary: parsedRecord.summary ?? null, sections }, - ); - } else { - review = await context.reviewService.createReview( - { userId, conversationId: assistant.conversationId, assistantMessageId: messageId }, - parsedArgs, - ); - } - const expiredReviews = await context.reviewService.expirePreviousReviews( - userId, - assistant.conversationId, - review.id, - ); - await Promise.all( - expiredReviews.map(async (expired) => { - const oldAssistant = await context.messages.findOne({ - where: { id: expired.assistantMessageId, conversationId: assistant.conversationId }, - }); - const oldA2ui = oldAssistant?.metadata?.a2uiReview; - if (oldAssistant && oldA2ui && typeof oldA2ui === 'object' && !Array.isArray(oldA2ui)) { - oldAssistant.metadata = { - ...oldAssistant.metadata, - a2uiReview: context.reviewService.serialize(expired), - }; - await context.messages.save(oldAssistant); - } - emit('ui.review', { - messageId: expired.assistantMessageId, - review: context.reviewService.serialize(expired), - }); - emit('ui.artifact', { - messageId: expired.assistantMessageId, - artifact: buildA2uiArtifact({ - type: 'review', - id: expired.id, - status: expired.status === 'submitted' ? 'submitted' : 'expired', - messageId: expired.assistantMessageId, - conversationId: assistant.conversationId, - payload: context.reviewService.serialize(expired), - }), - }); - }), - ); - assistant.metadata = { - ...assistant.metadata, - a2uiReview: context.reviewService.serialize(review), - }; - await context.messages.save(assistant); - - await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成导入预览,等待用户确认' }, emit); - emit('ui.review', { - messageId, - review: context.reviewService.serialize(review), - }); - emit('ui.artifact', { - messageId, - artifact: buildA2uiArtifact({ - type: 'review', - id: review.id, - status: review.status === 'submitted' ? 'submitted' : 'pending', - messageId, - conversationId: assistant.conversationId, - payload: context.reviewService.serialize(review), - }), - }); - return JSON.stringify({ - status: 'success', - reviewId: review.id, - message: '导入预览已显示给用户,请提示用户审阅并确认', - }); - } catch (reason) { - const errorMessage = - reason instanceof Error && reason.message ? reason.message.slice(0, 120) : '导入预览参数无效'; - await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: errorMessage, error: errorMessage }, emit); - return JSON.stringify({ status: 'failed', error: errorMessage }); - } -} - export async function executeRenderChart( context: AiChatServiceContext, messageId: number, @@ -248,10 +117,6 @@ export async function executeRenderChart( await context.messages.save(assistant); await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成图表' }, emit); - emit('ui.chart', { - messageId, - chart: context.chartService.serialize(chart), - }); emit('ui.artifact', { messageId, artifact: buildA2uiArtifact({ @@ -275,6 +140,5 @@ export async function executeRenderChart( } export { - compactImportWizard, executeStartImportWizard, } from './ai-chat.tool-actions.import'; diff --git a/apps/server/src/ai-chat/ai-chat.types.ts b/apps/server/src/ai-chat/ai-chat.types.ts index bb6f598c..80476591 100644 --- a/apps/server/src/ai-chat/ai-chat.types.ts +++ b/apps/server/src/ai-chat/ai-chat.types.ts @@ -182,9 +182,6 @@ export type AiSseEventName = | 'tool.started' | 'tool.completed' | 'tool.failed' - | 'ui.form' - | 'ui.review' - | 'ui.chart' | 'ui.artifact' | 'ui.import_wizard' | 'attachment.processed' diff --git a/apps/server/src/ai-chat/ai-form.service.ts b/apps/server/src/ai-chat/ai-form.service.ts index a72488fe..74e3b2e9 100644 --- a/apps/server/src/ai-chat/ai-form.service.ts +++ b/apps/server/src/ai-chat/ai-form.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { uuidV7 } from '../common/uuid-v7'; import { AiForm, type AiFormField } from './entities/ai-form.entity'; +import { isPlainRecord, requireString } from './ai-validation'; export const A2UI_FIELD_TYPES = ['input', 'textarea', 'number', 'select', 'date'] as const; @@ -40,34 +41,10 @@ interface ValidatedFormSchema { fields: AiFormField[]; } -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - function isShortString(value: unknown, max: number): value is string { return typeof value === 'string' && value.length <= max; } -function requireString( - value: unknown, - label: string, - max: number, - optional = false, -): string { - if (value === undefined || value === null) { - if (optional) return ''; - throw new BadRequestException(`${label}不能为空`); - } - if (typeof value !== 'string' || !value.trim()) { - throw new BadRequestException(`${label}必须是字符串`); - } - const trimmed = value.trim(); - if (trimmed.length > max) { - throw new BadRequestException(`${label}长度不能超过 ${max}`); - } - return trimmed; -} - /** * Server-side A2UI form lifecycle: * schema validation + persistence, owned lookup, submitted-value @@ -171,7 +148,7 @@ export class AiFormService { return result; } - /** Public shape sent via `ui.form` SSE and mirrored into message metadata. */ + /** Public shape used by message metadata and the unified `ui.artifact` payload. */ serialize(form: AiForm): Record { return { id: form.id, diff --git a/apps/server/src/ai-chat/ai-review.enrich.ts b/apps/server/src/ai-chat/ai-review.enrich.ts index 1d932e79..386f8d9c 100644 --- a/apps/server/src/ai-chat/ai-review.enrich.ts +++ b/apps/server/src/ai-chat/ai-review.enrich.ts @@ -1,10 +1,4 @@ -import { DataSource, IsNull, Repository } from 'typeorm'; import { Organization } from '../entities/organization.entity'; -import { Room } from '../entities/room.entity'; -import { Student } from '../entities/student.entity'; -import { Occupancy } from '../entities/occupancy.entity'; -import type { AiReviewSection } from './entities/ai-review.entity'; -import { DATE_RE, MAX_ISSUES, normalizePhone, toDateString } from './ai-review.shared'; export function resolveOrganizationId( raw: unknown, @@ -20,258 +14,3 @@ export function resolveOrganizationId( const match = organizations.find((org) => org.name === text || org.code === text); return match?.id ?? null; } - -/** - * Preview-time database validation. The AI's parsed rows are checked - * against the current system (organizations, duplicate students/rooms, - * occupancy state, transfer targets) and the findings are appended to - * each section's issues so the user sees them BEFORE confirming. - * Problems found here do not block preview creation; the import phase - * re-checks everything and skips problematic rows. - */ -export async function enrichWithIssues( - dataSource: DataSource, - sections: AiReviewSection[], -): Promise { - try { - const organizationRepo = dataSource.getRepository(Organization); - const studentRepo = dataSource.getRepository(Student); - const roomRepo = dataSource.getRepository(Room); - const occupancyRepo = dataSource.getRepository(Occupancy); - const organizations = await organizationRepo.find({ where: { status: 'active' } }); - - const roomSections = sections.filter((section) => section.type === 'rooms'); - const incomingRoomNumbers = new Set( - roomSections.flatMap((section) => - (section.rows ?? []) - .map((row) => - row.roomNumber === undefined ? '' : String(row.roomNumber).trim(), - ) - .filter(Boolean), - ), - ); - - const enriched: AiReviewSection[] = []; - for (const section of sections) { - const issues = [...section.issues]; - if (section.type === 'students') { - await enrichStudentIssues(section, issues, organizations, studentRepo); - } else if (section.type === 'rooms') { - await enrichRoomIssues(section, issues, roomRepo); - } else if (section.type === 'transfers') { - await enrichTransferIssues( - section, - issues, - studentRepo, - roomRepo, - occupancyRepo, - incomingRoomNumbers, - ); - } else if (section.type === 'checkins') { - await enrichCheckinIssues( - section, - issues, - studentRepo, - roomRepo, - occupancyRepo, - ); - } - enriched.push({ - ...section, - issues: [...new Set(issues)].slice(-MAX_ISSUES), - }); - } - return enriched; - } catch { - // Database validation is best-effort; fall back to model-provided issues. - return sections; - } -} - -async function enrichStudentIssues( - section: AiReviewSection, - issues: string[], - organizations: Organization[], - studentRepo: Repository, -): Promise { - const seen = new Set(); - for (const row of section.rows) { - const name = row.name === undefined || row.name === null ? '' : String(row.name).trim(); - const phone = normalizePhone(row.phone); - const studentNo = - row.studentNo === undefined || row.studentNo === null - ? '' - : String(row.studentNo).trim(); - const organizationId = resolveOrganizationId(row.organization, organizations); - if (organizationId === null) { - issues.push(`学生「${name}」的所属机构无法识别,导入时将按本机构处理`); - } - const dedupeKey = phone ? `phone:${phone}` : studentNo ? `no:${studentNo}` : ''; - if (dedupeKey && seen.has(dedupeKey)) { - issues.push(`学生「${name}」与同一批次中的其他学生手机号/学号重复,导入时将跳过`); - } - seen.add(dedupeKey); - if (!dedupeKey) continue; - const existing = phone - ? await studentRepo.findOne({ where: { phone } }) - : await studentRepo.findOne({ where: { studentNo } }); - if (existing) { - issues.push(`学生「${name}」已存在(按手机号/学号匹配),导入时将跳过`); - } - } -} - -async function enrichRoomIssues( - section: AiReviewSection, - issues: string[], - roomRepo: Repository, -): Promise { - const seen = new Set(); - for (const row of section.rows) { - const roomNumber = - row.roomNumber === undefined || row.roomNumber === null - ? '' - : String(row.roomNumber).trim(); - if (!roomNumber) continue; - if (seen.has(roomNumber)) { - issues.push(`宿舍「${roomNumber}」在同一批次中重复,导入时将跳过`); - continue; - } - seen.add(roomNumber); - const existing = await roomRepo.findOne({ where: { roomNumber } }); - if (existing) { - issues.push(`宿舍「${roomNumber}」已存在,导入时将跳过`); - } - } -} - -async function enrichCheckinIssues( - section: AiReviewSection, - issues: string[], - studentRepo: Repository, - roomRepo: Repository, - occupancyRepo: Repository, -): Promise { - const seen = new Set(); - for (const row of section.rows) { - const name = row.name === undefined || row.name === null ? '' : String(row.name).trim(); - const phone = normalizePhone(row.phone); - const studentNo = - row.studentNo === undefined || row.studentNo === null - ? '' - : String(row.studentNo).trim(); - const roomNumber = - row.roomNumber === undefined || row.roomNumber === null - ? '' - : String(row.roomNumber).trim(); - if (!name || !roomNumber) { - issues.push('存在姓名或宿舍号为空的入住记录行,导入时将跳过'); - continue; - } - if (!phone && !studentNo) { - issues.push(`学生「${name}」缺少手机号/学号,无法关联或创建学生`); - continue; - } - const dedupeKey = phone ? `phone:${phone}` : `no:${studentNo}`; - if (seen.has(dedupeKey)) { - issues.push(`学生「${name}」与同一批次中的其他行手机号/学号重复,导入时将跳过`); - } - seen.add(dedupeKey); - - const rawDate = - row.checkInDate === undefined || row.checkInDate === null - ? '' - : String(row.checkInDate).trim(); - if (rawDate && !DATE_RE.test(rawDate)) { - issues.push(`学生「${name}」的入住日期格式无效(应为 YYYY-MM-DD),导入时按当天处理`); - } - - const student = phone - ? await studentRepo.findOne({ where: { phone } }) - : await studentRepo.findOne({ where: { studentNo } }); - if (!student) { - issues.push(`学生「${name}」不存在,导入时将自动创建并归入本机构`); - } - const room = roomNumber - ? await roomRepo.findOne({ where: { roomNumber } }) - : null; - if (!room) { - issues.push(`宿舍「${roomNumber}」不存在,导入时将自动创建`); - } - - const checkOutDate = toDateString(row.checkOutDate); - if (student && !checkOutDate) { - const active = await occupancyRepo.findOne({ - where: { studentId: student.id, checkOutDate: IsNull() }, - order: { id: 'DESC' }, - }); - if (active) { - issues.push( - `学生「${name}」当前已在住,导入时将跳过(如为历史记录请填写退宿日期)`, - ); - } - } - } -} - -async function enrichTransferIssues( - section: AiReviewSection, - issues: string[], - studentRepo: Repository, - roomRepo: Repository, - occupancyRepo: Repository, - incomingRoomNumbers: Set, -): Promise { - for (const row of section.rows) { - const studentNo = - row.studentNo === undefined || row.studentNo === null - ? '' - : String(row.studentNo).trim(); - const phone = normalizePhone(row.studentPhone); - const newRoomNumber = - row.newRoom === undefined || row.newRoom === null - ? '' - : String(row.newRoom).trim(); - const student = studentNo - ? await studentRepo.findOne({ where: { studentNo } }) - : phone - ? await studentRepo.findOne({ where: { phone } }) - : null; - if (!student) { - issues.push(`换宿到「${newRoomNumber}」的学生不存在(缺少手机号/学号),导入时将跳过`); - continue; - } - const active = await occupancyRepo.findOne({ - where: { studentId: student.id, checkOutDate: IsNull() }, - order: { id: 'DESC' }, - }); - if (!active) { - issues.push(`学生「${student.name}」当前没有在住记录,无法换宿`); - continue; - } - const oldRoom = await roomRepo.findOne({ where: { id: active.roomId } }); - const oldRoomNumber = oldRoom?.roomNumber ?? String(active.roomId); - const expectedOldRoom = - row.oldRoom === undefined || row.oldRoom === null - ? '' - : String(row.oldRoom).trim(); - if (expectedOldRoom && expectedOldRoom !== oldRoomNumber) { - issues.push( - `学生「${student.name}」原宿舍为「${oldRoomNumber}」,与行内填写的「${expectedOldRoom}」不一致`, - ); - } - const targetExists = - incomingRoomNumbers.has(newRoomNumber) || - Boolean(await roomRepo.findOne({ where: { roomNumber: newRoomNumber } })); - if (!newRoomNumber) { - issues.push('存在目标宿舍为空的行,导入时将跳过'); - } else if (!targetExists) { - issues.push( - `学生「${student.name}」的目标宿舍「${newRoomNumber}」不存在,且本次导入未包含该宿舍`, - ); - } - if (newRoomNumber && oldRoomNumber === newRoomNumber) { - issues.push(`学生「${student.name}」的目标宿舍与当前宿舍相同`); - } - } -} diff --git a/apps/server/src/ai-chat/ai-review.service.spec.ts b/apps/server/src/ai-chat/ai-review.service.spec.ts index a783d44f..a2b5a7b6 100644 --- a/apps/server/src/ai-chat/ai-review.service.spec.ts +++ b/apps/server/src/ai-chat/ai-review.service.spec.ts @@ -1,6 +1,5 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; import { AiReviewService } from './ai-review.service'; -import type { ExcelSheetRows } from './ai-excel-reader.service'; function createService(overrides: Record = {}) { const reviews = { @@ -19,12 +18,6 @@ function createService(overrides: Record = {}) { return { service, reviews, dataSource }; } -const baseArgs = { - userId: 7, - conversationId: 3, - assistantMessageId: 12, -}; - const validSchema = { title: '新生入住批量导入', summary: '来自报名 Excel', @@ -48,521 +41,6 @@ const validSchema = { }; describe('AiReviewService', () => { - describe('createReview', () => { - function bigSchema(sectionCount: number) { - const columns = Array.from({ length: 30 }, (_, i) => ({ - key: `c${i + 1}`, - title: `列${i + 1}`, - })); - const cell = '中'.repeat(200); - const rows = Array.from({ length: 500 }, (_, _i) => - Object.fromEntries(columns.map((column) => [column.key, cell])), - ); - return { - title: '大体积导入', - summary: null, - sections: Array.from({ length: sectionCount }, (_, i) => ({ - key: (['students', 'rooms', 'transfers'] as const)[i], - title: `学生${i + 1}`, - kind: 'table', - columns, - rows, - issues: [], - })), - }; - } - - it('校验通过的 schema 落库并保留完整 sections', async () => { - const { service, reviews } = createService(); - const review = await service.createReview(baseArgs, validSchema); - expect(reviews.create).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 7, - conversationId: 3, - assistantMessageId: 12, - title: '新生入住批量导入', - status: 'pending', - }), - ); - expect(review.id).toBeTruthy(); - const sections = JSON.parse(review.sectionsJson) as unknown[]; - expect(sections).toHaveLength(1); - expect((sections[0] as { rows: unknown[] }).rows).toHaveLength(2); - }); - - it('允许大体积合法预览(远超 256KB),不再因字节上限失败', async () => { - const { service } = createService(); - const review = await service.createReview(baseArgs, bigSchema(1)); - expect(review.sectionsJson.length).toBeGreaterThan(256 * 1024); - }); - - it('超过 12MB 总上限的预览仍被拒绝', async () => { - const { service } = createService(); - await expect(service.createReview(baseArgs, bigSchema(2))).rejects.toThrow('预览数据过大'); - }); - - it.each([ - ['标题缺失', { sections: validSchema.sections }, '预览标题'], - ['未知顶层字段', { ...validSchema, hack: 1 }, '未知属性'], - ['分表为空', { ...validSchema, sections: [] }, '至少需要一个分表'], - [ - '分表超过20个', - { - ...validSchema, - sections: Array.from({ length: 21 }, (_, i) => ({ - ...validSchema.sections[0], - key: `students_${i}`, - title: `分表${i}`, - })), - }, - '不能超过 20', - ], - [ - '分表类型无法解析', - { - ...validSchema, - sections: [{ ...validSchema.sections[0], key: 'hackers', type: undefined }], - }, - '无法解析业务类型', - ], - [ - '显式非法 type 被拒绝', - { - ...validSchema, - sections: [{ ...validSchema.sections[0], type: 'hackers' }], - }, - '分表业务类型不支持', - ], - [ - '分表标识重复', - { - ...validSchema, - sections: [validSchema.sections[0], validSchema.sections[0]], - }, - '分表标识重复', - ], - [ - 'kind 非 table', - { - ...validSchema, - sections: [{ ...validSchema.sections[0], kind: 'chart' }], - }, - '只能是 table', - ], - [ - '列缺失', - { - ...validSchema, - sections: [{ ...validSchema.sections[0], columns: [] }], - }, - '至少需要一个列', - ], - [ - '行数超限', - { - ...validSchema, - sections: [ - { - ...validSchema.sections[0], - rows: Array.from({ length: 501 }, (_, i) => ({ name: `学生${i}` })), - }, - ], - }, - '不能超过 500', - ], - [ - '单元格类型非法', - { - ...validSchema, - sections: [ - { - ...validSchema.sections[0], - rows: [{ name: '张三', phone: { hack: true } }], - }, - ], - }, - '类型不支持', - ], - ])('非法 schema 被拒绝:%s', async (_name, schema, messagePart) => { - const { service } = createService(); - await expect(service.createReview(baseArgs, schema)).rejects.toBeInstanceOf( - BadRequestException, - ); - await expect(service.createReview(baseArgs, schema)).rejects.toThrow(messagePart); - }); - - it('行内未知列被剔除,不写入预览', async () => { - const { service } = createService(); - const review = await service.createReview(baseArgs, { - ...validSchema, - sections: [ - { - ...validSchema.sections[0], - rows: [{ name: '张三', phone: '13800138000', __proto_hack: 'x', token: 'abc' }], - }, - ], - }); - const sections = JSON.parse(review.sectionsJson) as Array<{ - rows: Array>; - }>; - expect(sections[0].rows[0]).toEqual({ name: '张三', phone: '13800138000' }); - }); - - it('同一业务类型允许多张 sheet,key 保持唯一', async () => { - const { service } = createService(); - const review = await service.createReview(baseArgs, { - title: '多入住 sheet', - sections: Array.from({ length: 6 }, (_, i) => ({ - key: `checkins_${i + 1}`, - type: 'checkins', - title: `入住${i + 1}`, - kind: 'table', - sheet: `Sheet${i + 1}`, - columns: [{ key: 'name', title: '姓名' }], - rows: [{ name: `学生${i + 1}` }], - issues: [], - })), - }); - const sections = service.parseSections(review.sectionsJson); - expect(sections).toHaveLength(6); - expect(sections.map((section) => section.type)).toEqual( - Array.from({ length: 6 }, () => 'checkins'), - ); - expect(new Set(sections.map((section) => section.key)).size).toBe(6); - expect(sections[0].sheet).toBe('Sheet1'); - }); - - it('旧格式 key 带类型前缀时自动解析 type', async () => { - const { service } = createService(); - const review = await service.createReview(baseArgs, { - ...validSchema, - sections: [ - { - key: 'checkins_girls_4', - title: '四人间女', - kind: 'table', - columns: [{ key: 'name', title: '姓名' }], - rows: [{ name: '张三' }], - issues: [], - }, - ], - }); - const sections = service.parseSections(review.sectionsJson); - expect(sections[0].type).toBe('checkins'); - expect(sections[0].key).toBe('checkins_girls_4'); - }); - - it('模型常见列名别名归一化为规范键名', async () => { - const { service } = createService(); - const review = await service.createReview(baseArgs, { - title: '别名测试', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [{ key: 'org', title: '机构' }], - rows: [{ org: '东校区' }], - issues: [], - }, - { - key: 'rooms', - title: '宿舍', - kind: 'table', - columns: [ - { key: 'roomNo', title: '房间号' }, - { key: 'capacity', title: '容量' }, - ], - rows: [{ roomNo: '4-401', capacity: 4 }], - issues: [], - }, - { - key: 'transfers', - title: '换宿', - kind: 'table', - columns: [ - { key: 'studentNo', title: '学号' }, - { key: 'fromRoom', title: '原宿舍' }, - { key: 'toRoom', title: '目标宿舍' }, - { key: 'date', title: '换宿日期' }, - ], - rows: [ - { - studentNo: 'S001', - fromRoom: '1-101', - toRoom: '4-401', - date: '2026-08-10', - }, - ], - issues: [], - }, - ], - }); - const sections = service.parseSections(review.sectionsJson); - expect(sections[0].columns[0].key).toBe('organization'); - expect(sections[0].rows[0]).toEqual({ organization: '东校区' }); - expect(sections[1].columns[0].key).toBe('roomNumber'); - expect(sections[1].rows[0]).toEqual({ roomNumber: '4-401', capacity: 4 }); - expect(sections[2].rows[0]).toEqual({ - studentNo: 'S001', - oldRoom: '1-101', - newRoom: '4-401', - transferDate: '2026-08-10', - }); - }); - - it('预览生成时按数据库校验机构、重复与换宿对象并追加 issues', async () => { - const { service } = createService(); - const review = await service.createReview(baseArgs, { - title: '预览校验', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - { key: 'organization', title: '机构' }, - ], - rows: [{ name: '小王', phone: '13800138000', organization: '不存在的机构' }], - issues: [], - }, - { - key: 'rooms', - title: '宿舍', - kind: 'table', - columns: [{ key: 'roomNumber', title: '房间号' }], - rows: [{ roomNumber: '9-901' }], - issues: [], - }, - { - key: 'transfers', - title: '换宿', - kind: 'table', - columns: [ - { key: 'studentNo', title: '学号' }, - { key: 'newRoom', title: '目标宿舍' }, - ], - rows: [{ studentNo: 'S001', newRoom: '9-901' }], - issues: [], - }, - ], - }); - const sections = service.parseSections(review.sectionsJson); - const studentIssues = sections.find((section) => section.key === 'students')?.issues ?? []; - const transferIssues = sections.find((section) => section.key === 'transfers')?.issues ?? []; - expect(studentIssues).toEqual( - expect.arrayContaining([expect.stringContaining('所属机构无法识别')]), - ); - expect(transferIssues).toEqual( - expect.arrayContaining([expect.stringContaining('学生不存在')]), - ); - }); - }); - - describe('buildSectionsFromWorkbook', () => { - const workbook: ExcelSheetRows[] = [ - { - name: '学生名单', - rows: [ - ['姓名', '手机号', '学号', '性别', '备注'], - ['张三', '13800138000', 'S001', '男', ''], - ['李四', '13900139000', 'S002', '女', '新生'], - ['', '', '', '', ''], - ], - }, - { - name: '宿舍安排', - rows: [ - ['宿舍号', '容量', '楼栋', '楼层'], - ['3-301', '4', '3号楼', '3'], - ['3-302', '6', '3号楼', '3'], - ], - }, - ]; - - it('按表头自动映射并保留文件原始行数据,未识别列进入 issues', async () => { - const { service } = createService(); - const sections = await service.buildSectionsFromWorkbook(workbook, { - sections: [{ key: 'students', title: '学生', sheet: '学生名单' }], - }); - expect(sections).toHaveLength(1); - expect(sections[0].rows).toEqual([ - { name: '张三', phone: '13800138000', studentNo: 'S001', gender: '男' }, - { name: '李四', phone: '13900139000', studentNo: 'S002', gender: '女' }, - ]); - expect(sections[0].columns.map((column) => column.key)).toEqual([ - 'name', - 'phone', - 'studentNo', - 'gender', - ]); - expect(sections[0].issues.join('')).toContain('备注'); - }); - - it('支持显式 sourceHeader 列映射与 headerRow', async () => { - const { service } = createService(); - const custom: ExcelSheetRows[] = [ - { - name: 'Sheet1', - rows: [['忽略行'], ['学生姓名', '联系方式'], ['王五', '13700137000']], - }, - ]; - const sections = await service.buildSectionsFromWorkbook(custom, { - sections: [ - { - key: 'students', - title: '学生', - sheet: 'Sheet1', - headerRow: 2, - columns: [ - { key: 'name', title: '姓名', sourceHeader: '学生姓名' }, - { key: 'phone', title: '手机号', sourceHeader: '联系方式' }, - ], - }, - ], - }); - expect(sections[0].rows).toEqual([{ name: '王五', phone: '13700137000' }]); - }); - - it('入住记录表头自动映射为规范列名', async () => { - const { service } = createService(); - const custom: ExcelSheetRows[] = [ - { - name: '入住名单', - rows: [ - ['宿舍号', '姓名', '手机号', '入住日期'], - ['5-501', '於嘉丽', '13611112222', '2026-08-01'], - ['5-502', '刘禹含', '13611113333', '2026/08/02'], - ], - }, - ]; - const sections = await service.buildSectionsFromWorkbook(custom, { - sections: [{ key: 'checkins', title: '入住记录', sheet: '入住名单' }], - }); - expect(sections[0].rows).toEqual([ - { name: '於嘉丽', phone: '13611112222', roomNumber: '5-501', checkInDate: '2026-08-01' }, - { name: '刘禹含', phone: '13611113333', roomNumber: '5-502', checkInDate: '2026/08/02' }, - ]); - }); - - it('英文表头映射为 camelCase 规范键(rooms/transfers/checkins)', async () => { - const { service } = createService(); - const custom: ExcelSheetRows[] = [ - { - name: 'Rooms', - rows: [ - ['RoomNumber', 'RoomType'], - ['3-301', '四人间'], - ], - }, - { - name: 'Transfers', - rows: [ - ['StudentNo', 'StudentPhone', 'OldRoom', 'NewRoom', 'TransferDate'], - ['S001', '13800138000', '3-301', '3-302', '2026-08-05'], - ], - }, - { - name: 'Checkins', - rows: [ - ['StudentNo', 'RoomNumber', 'CheckInDate'], - ['S001', '3-301', '2026-08-01'], - ], - }, - ]; - const sections = await service.buildSectionsFromWorkbook(custom, { - sections: [ - { key: 'rooms', title: '宿舍', sheet: 'Rooms' }, - { key: 'transfers', title: '换宿', sheet: 'Transfers' }, - { key: 'checkins', title: '入住', sheet: 'Checkins' }, - ], - }); - const byKey = Object.fromEntries(sections.map((section) => [section.key, section])); - expect(byKey.rooms.rows).toEqual([{ roomNumber: '3-301', roomType: '四人间' }]); - expect(byKey.transfers.rows).toEqual([ - { - studentNo: 'S001', - studentPhone: '13800138000', - oldRoom: '3-301', - newRoom: '3-302', - transferDate: '2026-08-05', - }, - ]); - expect(byKey.checkins.rows).toEqual([ - { studentNo: 'S001', roomNumber: '3-301', checkInDate: '2026-08-01' }, - ]); - }); - - it('工作表不存在时抛出明确错误', async () => { - const { service } = createService(); - await expect( - service.buildSectionsFromWorkbook(workbook, { - sections: [{ key: 'rooms', title: '宿舍', sheet: '不存在的表' }], - }), - ).rejects.toThrow('找不到工作表'); - }); - - it('分表标识重复或非法时拒绝', async () => { - const { service } = createService(); - await expect( - service.buildSectionsFromWorkbook(workbook, { - sections: [ - { key: 'students', title: '学生' }, - { key: 'students', title: '学生2' }, - ], - }), - ).rejects.toThrow('分表标识重复'); - await expect( - service.buildSectionsFromWorkbook(workbook, { - sections: [{ key: 'hackers', title: '入侵' }], - }), - ).rejects.toThrow('无法解析业务类型'); - }); - - it('同一类型多张 sheet 合并生成,并保留各自 key/sheet', async () => { - const { service } = createService(); - const multiSheet: ExcelSheetRows[] = [ - { - name: '四人间女', - rows: [ - ['姓名', '手机号', '宿舍号', '入住日期'], - ['张三', '13800138000', '4-401', '2026-08-01'], - ], - }, - { - name: '四人间男', - rows: [ - ['姓名', '手机号', '宿舍号', '入住日期'], - ['李四', '13900139000', '4-402', '2026-08-01'], - ], - }, - ]; - const sections = await service.buildSectionsFromWorkbook(multiSheet, { - sections: [ - { - key: 'checkins_girls_4', - type: 'checkins', - title: '四人间女', - sheet: '四人间女', - }, - { - key: 'checkins_boys_4', - type: 'checkins', - title: '四人间男', - sheet: '四人间男', - }, - ], - }); - expect(sections).toHaveLength(2); - expect(sections.map((section) => [section.key, section.type, section.sheet])).toEqual([ - ['checkins_girls_4', 'checkins', '四人间女'], - ['checkins_boys_4', 'checkins', '四人间男'], - ]); - }); - }); - describe('findOwnedPending', () => { it('只返回本人 pending 预览', async () => { const review = { id: 'review-1', userId: 7, status: 'pending' }; @@ -581,23 +59,6 @@ describe('AiReviewService', () => { }); }); - describe('findPendingByAssistantMessage', () => { - it('按 assistant 消息返回最新的 pending 预览', async () => { - const review = { id: 'review-1', assistantMessageId: 12, status: 'pending' }; - const { service, reviews } = createService({ findOne: jest.fn().mockResolvedValue(review) }); - await expect(service.findPendingByAssistantMessage(12)).resolves.toBe(review); - expect(reviews.findOne).toHaveBeenCalledWith({ - where: { assistantMessageId: 12, status: 'pending' }, - order: { createdAt: 'DESC' }, - }); - }); - - it('没有待确认预览时返回 null', async () => { - const { service } = createService({ findOne: jest.fn().mockResolvedValue(null) }); - await expect(service.findPendingByAssistantMessage(12)).resolves.toBeNull(); - }); - }); - describe('serialize', () => { it('回传前端所需结构', () => { const { service } = createService(); diff --git a/apps/server/src/ai-chat/ai-review.service.ts b/apps/server/src/ai-chat/ai-review.service.ts index b4f40c8e..0effea4f 100644 --- a/apps/server/src/ai-chat/ai-review.service.ts +++ b/apps/server/src/ai-chat/ai-review.service.ts @@ -1,27 +1,16 @@ -// aislop-ignore-file: duplicate-block -- 导入校验循环结构相似,逻辑已复用现有助手 -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, In, Repository } from 'typeorm'; -import { uuidV7 } from '../common/uuid-v7'; +import { DataSource, Repository } from 'typeorm'; import { AiReview } from './entities/ai-review.entity'; import type { AiReviewSection, AiReviewSectionType, } from './entities/ai-review.entity'; -import type { ExcelSheetRows } from './ai-excel-reader.service'; import { AiReviewStepSubmitResult, AiReviewSubmitResult, - MAX_SECTIONS_JSON_BYTES, - withInitialSectionState, } from './ai-review.shared'; -import { buildSectionsFromWorkbookAsync, parseSections } from './ai-review.workbook'; -import { validateSchema } from './ai-review.validation'; -import { enrichWithIssues } from './ai-review.enrich'; +import { parseSections } from './ai-review.workbook'; import { submitAll, submitGroup, @@ -30,14 +19,11 @@ import { import type { AiReviewSubmitContext } from './ai-review.submit'; /** - * A2UI batch-import review lifecycle. + * A2UI batch-import review confirmation lifecycle. * - * The model parses an uploaded workbook (students / rooms / transfers), - * calls `render_review`, and the user inspects per-table preview cards - * before confirming. Confirmation runs each section in its own - * transaction in dependency order: students → rooms → transfers → - * checkins. Per-row problems are collected as issues and the row is - * skipped instead of failing the whole import. + * Existing previews can be confirmed per section, per group, or all at + * once; each section is imported in its own transaction in dependency + * order: students → rooms → transfers → checkins. */ @Injectable() export class AiReviewService { @@ -51,38 +37,6 @@ export class AiReviewService { return { reviews: this.reviews, dataSource: this.dataSource }; } - /** - * Validate `render_review` arguments and persist a pending review. - * Throws BadRequestException when the schema is unsafe/invalid. - */ - async createReview( - input: { userId: number; conversationId: number; assistantMessageId: number }, - rawArgs: unknown, - ): Promise { - const schema = validateSchema(rawArgs); - const sections = (await enrichWithIssues(this.dataSource, schema.sections)).map( - withInitialSectionState, - ); - const sectionsJson = JSON.stringify(sections); - if (Buffer.byteLength(sectionsJson, 'utf8') > MAX_SECTIONS_JSON_BYTES) { - throw new BadRequestException('预览数据过大'); - } - return this.reviews.save( - this.reviews.create({ - id: uuidV7(), - userId: input.userId, - conversationId: input.conversationId, - assistantMessageId: input.assistantMessageId, - title: schema.title, - summary: schema.summary, - sectionsJson, - status: 'pending', - resultSummary: null, - submittedAt: null, - }), - ); - } - async findOwnedPending(reviewId: string, userId: number): Promise { const review = await this.reviews.findOne({ where: { id: reviewId, userId, status: 'pending' }, @@ -91,26 +45,6 @@ export class AiReviewService { return review; } - /** - * Mark every other pending review in the same conversation as expired. - * Called after a new render_review is successfully created so older cards - * are superseded instead of silently staying confirmable. - */ - async expirePreviousReviews( - userId: number, - conversationId: number, - exceptReviewId: string, - ): Promise { - const pending = await this.reviews.find({ - where: { userId, conversationId, status: 'pending' }, - }); - const expired = pending.filter((review) => review.id !== exceptReviewId); - if (expired.length === 0) return []; - const ids = expired.map((review) => review.id); - await this.reviews.update({ id: In(ids) }, { status: 'expired' }); - return expired.map((review) => ({ ...review, status: 'expired' as const })); - } - /** * Return a review owned by the user regardless of overall status. * Used by step confirmation so an already-completed card can respond @@ -124,30 +58,7 @@ export class AiReviewService { return review; } - /** - * Return the newest pending review bound to an assistant message, if any. - * Used to guarantee at most one batch-import preview card per message. - */ - async findPendingByAssistantMessage(assistantMessageId: number): Promise { - return this.reviews.findOne({ - where: { assistantMessageId, status: 'pending' }, - order: { createdAt: 'DESC' }, - }); - } - - /** - * 服务端直接解析上传的 Excel 生成审阅分表:行数据来自文件原文, - * 不经过模型转抄,避免漏行/错值。表头按内置字典自动映射, - * 模型可通过 sections[].columns[].sourceHeader 显式指定映射。 - */ - async buildSectionsFromWorkbook( - sheets: ExcelSheetRows[], - rawArgs: unknown, - ): Promise { - return await buildSectionsFromWorkbookAsync(sheets, rawArgs); - } - - /** Public shape sent via `ui.review` SSE and mirrored into message metadata. */ + /** Public shape used by message metadata and the unified `ui.artifact` payload. */ serialize(review: AiReview): Record { return { id: review.id, diff --git a/apps/server/src/ai-chat/ai-review.shared.ts b/apps/server/src/ai-chat/ai-review.shared.ts index 615920e4..f917ba61 100644 --- a/apps/server/src/ai-chat/ai-review.shared.ts +++ b/apps/server/src/ai-chat/ai-review.shared.ts @@ -5,25 +5,12 @@ import type { AiReviewSectionType, } from './entities/ai-review.entity'; -export const MAX_TITLE = 50; -export const MAX_SUMMARY = 500; -export const MAX_SECTIONS = 20; -export const MAX_SECTION_TITLE = 50; -export const MAX_COLUMNS = 30; -export const MAX_COLUMN_KEY = 50; -export const MAX_COLUMN_TITLE = 50; -export const MAX_ROWS = 500; -export const MAX_CELL_LENGTH = 200; export const MAX_ISSUES = 50; -export const MAX_ISSUE_LENGTH = 200; -export const MAX_SECTIONS_JSON_BYTES = 12 * 1024 * 1024; -export const MAX_SECTION_JSON_BYTES = Math.floor(MAX_SECTIONS_JSON_BYTES / MAX_SECTIONS); export const MAX_CAPACITY = 200; -export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; -export const COLUMN_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; export const SECTION_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/; -export const SECTION_TYPES = new Set([ +const SECTION_TYPES = new Set([ 'students', 'rooms', 'transfers', @@ -36,171 +23,6 @@ export const SECTION_DEPENDENCIES: Record> = { - students: { - org: 'organization', - organizationName: 'organization', - orgName: 'organization', - }, - rooms: { - roomNo: 'roomNumber', - number: 'roomNumber', - }, - transfers: { - fromRoom: 'oldRoom', - currentRoom: 'oldRoom', - sourceRoom: 'oldRoom', - toRoom: 'newRoom', - targetRoom: 'newRoom', - destRoom: 'newRoom', - date: 'transferDate', - changeDate: 'transferDate', - moveDate: 'transferDate', - mobile: 'studentPhone', - phone: 'studentPhone', - }, - checkins: { - studentName: 'name', - mobile: 'phone', - roomNo: 'roomNumber', - room: 'roomNumber', - date: 'checkInDate', - inDate: 'checkInDate', - checkinDate: 'checkInDate', - outDate: 'checkOutDate', - checkoutDate: 'checkOutDate', - }, -}; - -/** - * Excel 表头 → 规范列名。与 SECTION_ALIASES 合并使用; - * 键会被归一化(去空格/下划线/大小写),因此同时覆盖中文与英文写法。 - */ -export const SECTION_HEADER_ALIASES: Record> = { - students: { - 姓名: 'name', - 学生姓名: 'name', - name: 'name', - 手机号: 'phone', - 电话: 'phone', - 联系电话: 'phone', - phone: 'phone', - mobile: 'phone', - 学号: 'studentNo', - 学生编号: 'studentNo', - studentNo: 'studentNo', - studentno: 'studentNo', - 性别: 'gender', - gender: 'gender', - 机构: 'organization', - 所属机构: 'organization', - 校区: 'organization', - 组织: 'organization', - organization: 'organization', - }, - rooms: { - 房间号: 'roomNumber', - 宿舍号: 'roomNumber', - 房号: 'roomNumber', - roomNumber: 'roomNumber', - roomnumber: 'roomNumber', - 容量: 'capacity', - 床位数: 'capacity', - 床位: 'capacity', - capacity: 'capacity', - 楼栋: 'building', - 楼号: 'building', - building: 'building', - 楼层: 'floor', - floor: 'floor', - 房型: 'roomType', - 房间类型: 'roomType', - roomType: 'roomType', - }, - transfers: { - 学号: 'studentNo', - studentNo: 'studentNo', - studentno: 'studentNo', - 手机号: 'studentPhone', - 学生手机号: 'studentPhone', - 电话: 'studentPhone', - phone: 'studentPhone', - studentPhone: 'studentPhone', - 原宿舍: 'oldRoom', - 原房间: 'oldRoom', - oldRoom: 'oldRoom', - 目标宿舍: 'newRoom', - 新宿舍: 'newRoom', - newRoom: 'newRoom', - 换宿日期: 'transferDate', - 日期: 'transferDate', - transferDate: 'transferDate', - }, - checkins: { - 姓名: 'name', - 学生姓名: 'name', - name: 'name', - 手机号: 'phone', - 电话: 'phone', - phone: 'phone', - mobile: 'phone', - 学号: 'studentNo', - studentNo: 'studentNo', - 宿舍号: 'roomNumber', - 房间号: 'roomNumber', - roomNumber: 'roomNumber', - 楼栋: 'building', - building: 'building', - 性别: 'gender', - gender: 'gender', - 入住时间: 'checkInDate', - 入住日期: 'checkInDate', - checkInDate: 'checkInDate', - 计费起始日: 'billingStartDate', - 计费开始日: 'billingStartDate', - 退宿日期: 'checkOutDate', - 退宿时间: 'checkOutDate', - 离宿时间: 'checkOutDate', - 入住类型: 'stayType', - 住宿类型: 'stayType', - }, -}; - -export const SECTION_CANONICAL_KEYS: Record> = { - students: new Set(['name', 'phone', 'studentNo', 'gender', 'organization']), - rooms: new Set(['roomNumber', 'capacity', 'building', 'floor', 'roomType']), - transfers: new Set(['studentNo', 'studentPhone', 'oldRoom', 'newRoom', 'transferDate']), - checkins: new Set([ - 'name', - 'phone', - 'studentNo', - 'roomNumber', - 'checkInDate', - 'billingStartDate', - 'checkOutDate', - 'gender', - 'building', - 'stayType', - ]), -}; export interface AiReviewSubmitResult { students: { created: number; skipped: number; issues: string[] }; @@ -214,47 +36,13 @@ export type AiReviewSectionResult = | { created: number; skipped: number; issues: string[] } | { completed: number; skipped: number; issues: string[] }; -export interface ValidatedReviewSchema { - title: string; - summary: string | null; - sections: AiReviewSection[]; -} - export interface AiReviewStepSubmitResult { review: AiReview; result: AiReviewSectionResult; message: string; } -export function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -export function requireString( - value: unknown, - label: string, - max: number, - optional = false, -): string { - if (value === undefined || value === null) { - if (optional) return ''; - throw new BadRequestException(`${label}不能为空`); - } - if (typeof value !== 'string' || !value.trim()) { - throw new BadRequestException(`${label}必须是字符串`); - } - const trimmed = value.trim(); - if (trimmed.length > max) { - throw new BadRequestException(`${label}长度不能超过 ${max}`); - } - return trimmed; -} - -export function assertKeys(raw: Record, allowed: Set, label: string): void { - for (const key of Object.keys(raw)) { - if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`); - } -} +export { isPlainRecord } from './ai-validation'; export function toDateString(value: unknown): string | null { if (typeof value === 'string' && DATE_RE.test(value.trim())) return value.trim(); @@ -267,7 +55,7 @@ export function normalizePhone(value: unknown): string | null { return /^1[3-9]\d{9}$/.test(phone) ? phone : null; } -export function isSectionType(value: unknown): value is AiReviewSectionType { +function isSectionType(value: unknown): value is AiReviewSectionType { return typeof value === 'string' && SECTION_TYPES.has(value as AiReviewSectionType); } @@ -317,12 +105,3 @@ export function sectionResultMessage( } return `成功入住 ${(result as { completed: number }).completed} 条,跳过 ${result.skipped} 条`; } - -export function withInitialSectionState(section: AiReviewSection): AiReviewSection { - return { - ...section, - status: 'pending', - resultSummary: null, - submittedAt: null, - }; -} diff --git a/apps/server/src/ai-chat/ai-review.validation.ts b/apps/server/src/ai-chat/ai-review.validation.ts deleted file mode 100644 index af8c96e1..00000000 --- a/apps/server/src/ai-chat/ai-review.validation.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; -import type { - AiReviewRow, - AiReviewSection, - AiReviewSectionType, -} from './entities/ai-review.entity'; -import { - assertKeys, - COLUMN_KEYS_ALLOWED, - COLUMN_KEY_RE, - isPlainRecord, - MAX_CELL_LENGTH, - MAX_COLUMNS, - MAX_COLUMN_KEY, - MAX_COLUMN_TITLE, - MAX_ISSUES, - MAX_ISSUE_LENGTH, - MAX_ROWS, - MAX_SECTIONS, - MAX_SECTION_TITLE, - MAX_SUMMARY, - MAX_TITLE, - normalizeSectionType, - requireString, - SCHEMA_KEYS, - SECTION_ALIASES, - SECTION_KEYS_ALLOWED, - SECTION_KEY_RE, - ValidatedReviewSchema, -} from './ai-review.shared'; - -export function validateSchema(rawArgs: unknown): ValidatedReviewSchema { - if (!isPlainRecord(rawArgs)) throw new BadRequestException('导入预览参数必须是对象'); - assertKeys(rawArgs, SCHEMA_KEYS, '导入预览'); - const title = requireString(rawArgs.title, '预览标题', MAX_TITLE); - const summary = requireString(rawArgs.summary, '预览说明', MAX_SUMMARY, true) || null; - if (!Array.isArray(rawArgs.sections) || rawArgs.sections.length === 0) { - throw new BadRequestException('导入预览至少需要一个分表'); - } - if (rawArgs.sections.length > MAX_SECTIONS) { - throw new BadRequestException(`分表数量不能超过 ${MAX_SECTIONS}`); - } - const seenKeys = new Set(); - const sections = rawArgs.sections.map((item, index) => - validateSection(item, index, seenKeys), - ); - return { title, summary, sections }; -} - -function validateSection( - raw: unknown, - index: number, - seenKeys: Set, -): AiReviewSection { - if (!isPlainRecord(raw)) throw new BadRequestException(`第 ${index + 1} 个分表格式无效`); - assertKeys(raw, SECTION_KEYS_ALLOWED, `第 ${index + 1} 个分表`); - const key = requireString(raw.key, `第 ${index + 1} 个分表标识`, MAX_COLUMN_KEY); - if (!SECTION_KEY_RE.test(key)) { - throw new BadRequestException( - `分表标识 ${key} 只能包含字母、数字、下划线(≤50)`, - ); - } - const type = normalizeSectionType(key, raw.type); - if (seenKeys.has(key)) throw new BadRequestException(`分表标识重复: ${key}`); - seenKeys.add(key); - const title = requireString(raw.title, `分表「${key}」标题`, MAX_SECTION_TITLE); - if (raw.kind !== 'table') throw new BadRequestException(`分表「${key}」的 kind 只能是 table`); - const sheet = - raw.sheet === undefined || raw.sheet === null - ? undefined - : requireString(raw.sheet, `分表「${key}」工作表`, MAX_SECTION_TITLE); - if (!Array.isArray(raw.columns) || raw.columns.length === 0) { - throw new BadRequestException(`分表「${key}」至少需要一个列`); - } - if (raw.columns.length > MAX_COLUMNS) { - throw new BadRequestException(`分表「${key}」的列数不能超过 ${MAX_COLUMNS}`); - } - const seenColumns = new Set(); - const aliases = SECTION_ALIASES[type] ?? {}; - const columns = raw.columns.map((column, columnIndex) => { - if (!isPlainRecord(column)) { - throw new BadRequestException(`分表「${key}」第 ${columnIndex + 1} 列格式无效`); - } - assertKeys(column, COLUMN_KEYS_ALLOWED, `分表「${key}」第 ${columnIndex + 1} 列`); - const rawKey = requireString(column.key, `分表「${key}」列名`, MAX_COLUMN_KEY); - const columnKey = aliases[rawKey] ?? rawKey; - if (!COLUMN_KEY_RE.test(columnKey)) { - throw new BadRequestException(`分表「${key}」列名 ${columnKey} 只能包含字母、数字、下划线`); - } - if (seenColumns.has(columnKey)) { - throw new BadRequestException(`分表「${key}」列名重复: ${columnKey}`); - } - seenColumns.add(columnKey); - const columnTitle = requireString(column.title, `分表「${key}」列「${columnKey}」标题`, MAX_COLUMN_TITLE); - return { key: columnKey, title: columnTitle }; - }); - if (!Array.isArray(raw.rows) || raw.rows.length > MAX_ROWS) { - throw new BadRequestException(`分表「${key}」的行数不能超过 ${MAX_ROWS}`); - } - const rows = raw.rows.map((row, rowIndex) => - validateRow(row, type, rowIndex, new Set(seenColumns), aliases), - ); - let issues: string[] = []; - if (raw.issues !== undefined) { - if (!Array.isArray(raw.issues) || raw.issues.length > MAX_ISSUES) { - throw new BadRequestException(`分表「${key}」的问题数不能超过 ${MAX_ISSUES}`); - } - issues = raw.issues.map((issue) => - requireString(issue, `分表「${key}」的问题`, MAX_ISSUE_LENGTH), - ); - } - return { - key, - type, - title, - kind: 'table', - ...(sheet ? { sheet } : {}), - columns, - rows, - issues, - }; -} - -function validateRow( - raw: unknown, - sectionType: AiReviewSectionType, - index: number, - knownColumns: Set, - aliases: Record, -): AiReviewRow { - if (!isPlainRecord(raw)) { - throw new BadRequestException(`分表「${sectionType}」第 ${index + 1} 行格式无效`); - } - const row: AiReviewRow = {}; - for (const [key, value] of Object.entries(raw)) { - const canonicalKey = aliases[key] ?? key; - if (!knownColumns.has(canonicalKey)) continue; - if (value === null || typeof value === 'boolean') { - row[canonicalKey] = value; - continue; - } - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - throw new BadRequestException( - `分表「${sectionType}」第 ${index + 1} 行 ${key} 必须是有效数字`, - ); - } - row[canonicalKey] = value; - continue; - } - if (typeof value === 'string') { - if (value.length > MAX_CELL_LENGTH) { - throw new BadRequestException( - `分表「${sectionType}」第 ${index + 1} 行 ${key} 长度超过 ${MAX_CELL_LENGTH}`, - ); - } - row[canonicalKey] = value; - continue; - } - throw new BadRequestException( - `分表「${sectionType}」第 ${index + 1} 行 ${key} 类型不支持`, - ); - } - return row; -} diff --git a/apps/server/src/ai-chat/ai-review.workbook.ts b/apps/server/src/ai-chat/ai-review.workbook.ts index 49e84dc0..fd700ff7 100644 --- a/apps/server/src/ai-chat/ai-review.workbook.ts +++ b/apps/server/src/ai-chat/ai-review.workbook.ts @@ -1,220 +1,6 @@ import { BadRequestException } from '@nestjs/common'; -import type { - AiReviewColumn, - AiReviewRow, - AiReviewSection, - AiReviewSectionType, -} from './entities/ai-review.entity'; -import type { ExcelSheetRows } from './ai-excel-reader.service'; -import { - isPlainRecord, - MAX_CELL_LENGTH, - MAX_COLUMN_TITLE, - MAX_ISSUES, - MAX_ROWS, - MAX_SECTIONS, - MAX_SECTION_JSON_BYTES, - MAX_SECTION_TITLE, - normalizeSectionType, - requireString, - SECTION_ALIASES, - SECTION_CANONICAL_KEYS, - SECTION_HEADER_ALIASES, - SECTION_KEY_RE, - sectionStatus, -} from './ai-review.shared'; - -export function buildSectionsFromWorkbook( - sheets: ExcelSheetRows[], - rawArgs: unknown, -): AiReviewSection[] { - if (!isPlainRecord(rawArgs)) throw new BadRequestException('导入预览参数必须是对象'); - const rawSections = rawArgs.sections; - if (!Array.isArray(rawSections) || rawSections.length === 0) { - throw new BadRequestException('至少需要一个分表'); - } - if (rawSections.length > MAX_SECTIONS) { - throw new BadRequestException(`分表不能超过 ${MAX_SECTIONS} 个`); - } - - const seen = new Set(); - const sections: AiReviewSection[] = []; - for (let index = 0; index < rawSections.length; index += 1) { - const raw: unknown = rawSections[index]; - if (!isPlainRecord(raw) || typeof raw.key !== 'string') { - throw new BadRequestException(`第 ${index + 1} 个分表格式无效`); - } - const key = raw.key.trim(); - if (!SECTION_KEY_RE.test(key)) { - throw new BadRequestException(`分表标识 ${key} 只能包含字母、数字、下划线(≤50)`); - } - const type = normalizeSectionType(key, raw.type); - if (seen.has(key)) throw new BadRequestException(`分表标识重复: ${key}`); - seen.add(key); - - const title = requireString(raw.title, '分表标题', MAX_SECTION_TITLE); - const rawSheet = raw.sheet; - const sheetName = - rawSheet === undefined || rawSheet === null - ? undefined - : typeof rawSheet === 'string' - ? rawSheet.trim() - : (JSON.stringify(rawSheet) ?? '').trim(); - const headerRow = raw.headerRow === undefined || raw.headerRow === null ? 1 : Number(raw.headerRow); - if (!Number.isInteger(headerRow) || headerRow < 1 || headerRow > 1000) { - throw new BadRequestException(`分表 ${key} 的 headerRow 无效`); - } - - const sheet = sheetName - ? (sheets.find((item) => item.name === sheetName) ?? - sheets.find((item) => item.name.includes(sheetName))) - : sheets[0]; - if (!sheet) { - throw new BadRequestException(`找不到工作表「${sheetName}」`); - } - - sections.push( - buildSectionFromSheet(key, type, title, sheet.name, sheet, headerRow, raw.columns), - ); - } - return sections; -} - -export async function buildSectionsFromWorkbookAsync( - sheets: ExcelSheetRows[], - rawArgs: unknown, -): Promise { - return await Promise.resolve(buildSectionsFromWorkbook(sheets, rawArgs)); -} - -function buildSectionFromSheet( - key: string, - type: AiReviewSectionType, - title: string, - sheetName: string, - sheet: ExcelSheetRows, - headerRow: number, - rawColumns: unknown, -): AiReviewSection { - const issues: string[] = []; - const aliasMap = buildHeaderAliasMap(type); - if (sheet.rows.length < headerRow) { - return { - key, - type, - title, - kind: 'table', - sheet: sheetName, - columns: [], - rows: [], - issues: [`工作表「${sheet.name}」没有第 ${headerRow} 行表头`], - }; - } - - const explicit = new Map(); - if (rawColumns !== undefined) { - if (!Array.isArray(rawColumns)) { - throw new BadRequestException(`分表 ${key} 的 columns 无效`); - } - for (const column of rawColumns) { - if (!isPlainRecord(column) || typeof column.key !== 'string') { - throw new BadRequestException(`分表 ${key} 的列定义无效`); - } - const canonical = aliasMap.get(normalizeHeader(column.key)); - if (!canonical || !SECTION_CANONICAL_KEYS[type].has(canonical)) { - throw new BadRequestException(`分表 ${key} 的列标识无效: ${column.key}`); - } - if (typeof column.sourceHeader === 'string' && column.sourceHeader.trim()) { - explicit.set(normalizeHeader(column.sourceHeader), canonical); - } else { - explicit.set(normalizeHeader(column.key), canonical); - } - } - } - - const headerCells = sheet.rows[headerRow - 1]; - const dataRows = sheet.rows.slice(headerRow); - const mapping = new Map(); - const columns: AiReviewColumn[] = []; - - for (let colIndex = 0; colIndex < headerCells.length; colIndex += 1) { - const header = String(headerCells[colIndex] ?? '').trim(); - if (!header) continue; - const canonical = - explicit.get(normalizeHeader(header)) ?? aliasMap.get(normalizeHeader(header)); - if (!canonical) { - issues.push(`列「${header}」未识别,已忽略`); - continue; - } - if (Array.from(mapping.values()).includes(canonical)) continue; - mapping.set(colIndex, canonical); - columns.push({ key: canonical, title: header.slice(0, MAX_COLUMN_TITLE) }); - } - - if (columns.length === 0) { - return { - key, - type, - title, - kind: 'table', - sheet: sheetName, - columns: [], - rows: [], - issues: [...issues, '没有识别到可导入的列'], - }; - } - - const rows: AiReviewRow[] = []; - let totalBytes = 0; - for (const cells of dataRows) { - const row: AiReviewRow = {}; - for (const [colIndex, canonical] of mapping) { - const raw = cells[colIndex]; - const text = raw === undefined || raw === null ? '' : String(raw).trim(); - if (!text) continue; - row[canonical] = text.length > MAX_CELL_LENGTH ? text.slice(0, MAX_CELL_LENGTH) : text; - } - if (Object.keys(row).length === 0) continue; - const rowBytes = Buffer.byteLength(JSON.stringify(row), 'utf8'); - if (totalBytes + rowBytes > MAX_SECTION_JSON_BYTES) { - issues.push(`「${title}」数据量过大,仅保留前 ${rows.length} 行`); - break; - } - totalBytes += rowBytes; - rows.push(row); - if (rows.length >= MAX_ROWS) { - issues.push(`「${title}」超过 ${MAX_ROWS} 行,仅保留前 ${MAX_ROWS} 行`); - break; - } - } - - return { - key, - type, - title, - kind: 'table', - sheet: sheetName, - columns, - rows, - issues: [...new Set(issues)].slice(-MAX_ISSUES), - }; -} - -function buildHeaderAliasMap(key: AiReviewSectionType): Map { - const merged: Record = { - ...SECTION_HEADER_ALIASES[key], - ...SECTION_ALIASES[key], - }; - const map = new Map(); - for (const [header, canonical] of Object.entries(merged)) { - map.set(normalizeHeader(header), canonical); - } - return map; -} - -function normalizeHeader(value: string): string { - return value.trim().toLowerCase().replace(/[\s_-]+/g, ''); -} +import type { AiReviewSection } from './entities/ai-review.entity'; +import { isPlainRecord, normalizeSectionType, sectionStatus } from './ai-review.shared'; export function parseSections(sectionsJson: string): AiReviewSection[] { let parsed: unknown; diff --git a/apps/server/src/ai-chat/ai-validation.ts b/apps/server/src/ai-chat/ai-validation.ts new file mode 100644 index 00000000..6bffb1ad --- /dev/null +++ b/apps/server/src/ai-chat/ai-validation.ts @@ -0,0 +1,31 @@ +import { BadRequestException } from '@nestjs/common'; + +export function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +export function requireString( + value: unknown, + label: string, + max: number, + optional = false, +): string { + if (value === undefined || value === null) { + if (optional) return ''; + throw new BadRequestException(`${label}不能为空`); + } + if (typeof value !== 'string' || !value.trim()) { + throw new BadRequestException(`${label}必须是字符串`); + } + const trimmed = value.trim(); + if (trimmed.length > max) { + throw new BadRequestException(`${label}长度不能超过 ${max}`); + } + return trimmed; +} + +export function assertKeys(raw: Record, allowed: Set, label: string): void { + for (const key of Object.keys(raw)) { + if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`); + } +} From 57639a3869e179f1f53b9d7546e381b9da71b073 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 15:05:20 +0800 Subject: [PATCH 14/43] =?UTF-8?q?refactor(admin):=20AI=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=94=B6=E6=95=9B=E4=B8=BA=E7=BB=9F=E4=B8=80=20ui.art?= =?UTF-8?q?ifact=20=E5=8D=8F=E8=AE=AE=EF=BC=8C=E7=A7=BB=E9=99=A4=20legacy?= =?UTF-8?q?=20=E4=BA=8B=E4=BB=B6=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/AiChat/AiMessageContent.tsx | 1 - .../AiChat/provider.integration.test.ts | 142 ++++-------------- apps/admin/src/components/AiChat/provider.ts | 26 ---- .../admin/src/components/AiChat/sseReducer.ts | 17 +-- 4 files changed, 32 insertions(+), 154 deletions(-) diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index e8344328..554d0e13 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -44,7 +44,6 @@ const toolLabels: Record = { search_bills: '查询账单', get_dashboard_stats: '读取经营概览', render_form: '生成表单', - render_review: '生成导入预览', render_chart: '生成图表', start_import_wizard: '生成导入向导', create_student: '创建学生', diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index 00251b7d..dcd28da9 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -123,34 +123,6 @@ describe('AI chat SSE message reducer', () => { expect(message.id).toBe(9); }); - it('ignores legacy ui.form events and only consumes ui.artifact (过渡期双发)', () => { - let message = reduceAiSseMessage(undefined, { - event: 'ui.form', - data: JSON.stringify({ messageId: 8, form: { id: 'form-1', title: '新增学生', fields: [] } }), - }); - // 旧事件不再写入 legacy 列表 - expect(message.forms).toHaveLength(0); - expect(message.uiArtifacts).toHaveLength(0); - - message = reduceAiSseMessage(message, { - event: 'ui.artifact', - data: JSON.stringify({ - messageId: 8, - artifact: { - id: 'form-1', - type: 'form', - status: 'pending', - messageId: 8, - conversationId: 3, - payload: { id: 'form-1', title: '新增学生', fields: [] }, - }, - }), - }); - - expect(message.uiArtifacts).toHaveLength(1); - expect(message.uiArtifacts?.[0].payload).toMatchObject({ id: 'form-1', title: '新增学生' }); - }); - it('merges ui.artifact events into uiArtifacts by id', () => { let message = reduceAiSseMessage(undefined, { event: 'ui.artifact', @@ -210,44 +182,6 @@ describe('AI chat SSE message reducer', () => { expect(message.forms?.[0].id).toBe('form-9'); }); - it('ignores legacy ui.review events and only consumes ui.artifact', () => { - const review = { - id: 'review-1', - title: '开学导入', - summary: '来自报名 Excel', - status: 'pending', - sections: [ - { - key: 'students', - type: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '张三', phone: '13800138000' }], - issues: [], - }, - ], - }; - let message = reduceAiSseMessage(undefined, { - event: 'ui.review', - data: JSON.stringify({ messageId: 8, review }), - }); - message = reduceAiSseMessage(message, { - event: 'ui.review', - data: JSON.stringify({ - messageId: 8, - review: { ...review, status: 'submitted', resultSummary: '{"students":{"created":1}}' }, - }), - }); - - // 旧事件不再写入 legacy 列表 - expect(message.reviews).toBeUndefined(); - expect(message.uiArtifacts).toHaveLength(0); - }); - it('shows model retrying state and clears it when content starts', () => { let message = reduceAiSseMessage(undefined, { event: 'model.retrying', @@ -299,37 +233,6 @@ describe('AI chat SSE message reducer', () => { expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' }); }); - it('ignores legacy ui.chart events and only consumes ui.artifact', () => { - const chart = { - id: 'chart-1', - title: '各班级人数', - chartType: 'bar', - columns: [ - { key: 'className', title: '班级' }, - { key: 'count', title: '人数' }, - ], - rows: [ - { className: '一班', count: 20 }, - { className: '二班', count: 15 }, - ], - }; - let message = reduceAiSseMessage(undefined, { - event: 'ui.chart', - data: JSON.stringify({ messageId: 8, chart }), - }); - message = reduceAiSseMessage(message, { - event: 'ui.chart', - data: JSON.stringify({ - messageId: 8, - chart: { ...chart, id: 'chart-2', title: '女生人数' }, - }), - }); - - // 旧事件不再写入 legacy 列表 - expect(message.charts).toBeUndefined(); - expect(message.uiArtifacts).toHaveLength(0); - }); - it('restores persisted charts from message.completed metadata', () => { const message = reduceAiSseMessage(undefined, { event: 'message.completed', @@ -457,15 +360,16 @@ describe('AI chat SSE message reducer', () => { } }); - it('routes submit-time ui.review to the original message instead of the streaming one', () => { + it('routes ui.artifact targeting another message to the external handler', () => { const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); - const onExternalReview = vi.fn(); - provider.onExternalReview = onExternalReview; - const review = { - id: 'review-1', - title: '批量导入', + const onExternalArtifact = vi.fn(); + provider.onExternalArtifact = onExternalArtifact; + const artifact = { + id: 'artifact-1', + type: 'form', status: 'submitted', - sections: [], + messageId: 12, + payload: { id: 'form-1', title: '批量导入', status: 'submitted' }, }; const origin = { id: 13, @@ -474,31 +378,37 @@ describe('AI chat SSE message reducer', () => { reasoningContent: '', toolRuns: [], attachments: [], - reviews: [], + uiArtifacts: [], }; const next = provider.transformMessage({ originMessage: origin, - chunk: { event: 'ui.review', data: JSON.stringify({ messageId: 12, review }) }, + chunk: { event: 'ui.artifact', data: JSON.stringify({ messageId: 12, artifact }) }, status: 'updating', chunks: [], responseHeaders: {} as Headers, }); - expect(onExternalReview).toHaveBeenCalledWith(12, review); + expect(onExternalArtifact).toHaveBeenCalledWith(12, artifact); expect(next).toBe(origin); - expect(next.reviews ?? []).toHaveLength(0); + expect(next.uiArtifacts ?? []).toHaveLength(0); }); - it('routes ui.review without an origin message to the external handler', () => { + it('routes ui.artifact without an origin message to the external handler', () => { const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); - const onExternalReview = vi.fn(); - provider.onExternalReview = onExternalReview; + const onExternalArtifact = vi.fn(); + provider.onExternalArtifact = onExternalArtifact; const next = provider.transformMessage({ chunk: { - event: 'ui.review', + event: 'ui.artifact', data: JSON.stringify({ messageId: 12, - review: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] }, + artifact: { + id: 'artifact-1', + type: 'review', + status: 'submitted', + messageId: 12, + payload: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] }, + }, }), }, status: 'updating', @@ -506,11 +416,11 @@ describe('AI chat SSE message reducer', () => { responseHeaders: {} as Headers, }); - expect(onExternalReview).toHaveBeenCalledWith( + expect(onExternalArtifact).toHaveBeenCalledWith( 12, - expect.objectContaining({ id: 'review-1' }), + expect.objectContaining({ id: 'artifact-1' }), ); - expect(next.reviews ?? []).toHaveLength(0); + expect(next.uiArtifacts ?? []).toHaveLength(0); }); it('tolerates non-JSON event data', () => { diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index f37e3b53..024dfcce 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -191,32 +191,6 @@ export class GongxueAiChatProvider extends AbstractChatProvider< this.onExternalArtifact?.(payload.messageId, payload.artifact); return info.originMessage ?? emptyAssistant(); } - if ( - event === 'ui.form' && - payload.form && - typeof payload.messageId === 'number' && - info.originMessage?.id !== payload.messageId - ) { - this.onExternalArtifact?.(payload.messageId, { - id: payload.form.id, - type: 'form', - status: payload.form.status ?? 'pending', - messageId: payload.messageId, - payload: payload.form, - }); - return info.originMessage ?? emptyAssistant(); - } - if ( - event === 'ui.review' && - payload.review && - typeof payload.messageId === 'number' && - info.originMessage?.id !== payload.messageId - ) { - // The submitted review belongs to the original assistant message; - // do not merge it into the message currently being streamed. - this.onExternalReview?.(payload.messageId, payload.review); - return info.originMessage ?? emptyAssistant(); - } return reduceAiSseMessage(info.originMessage, info.chunk); } } diff --git a/apps/admin/src/components/AiChat/sseReducer.ts b/apps/admin/src/components/AiChat/sseReducer.ts index 69ed7243..57d65fc3 100644 --- a/apps/admin/src/components/AiChat/sseReducer.ts +++ b/apps/admin/src/components/AiChat/sseReducer.ts @@ -25,10 +25,7 @@ export interface AiSsePayload { summary?: string | null; durationMs?: number | null; attachment?: AiAttachment; - form?: AiFormSchema; artifact?: AiArtifactSchema; - review?: AiReviewSchema; - chart?: AiChartSchema; wizard?: unknown; retry?: AiModelRetryInfo; message?: @@ -109,22 +106,21 @@ function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRu function applyMessagePayload( message: AiChatMessage, nested: AiSsePayload['message'], - payload: AiSsePayload, ): void { if (typeof nested !== 'object' || nested === null) return; // 历史消息兼容:老数据只有 metadata.a2uiForm/a2uiReview/a2uiChart, // 恢复为 legacy 字段供渲染层在 uiArtifacts 为空时回退使用。 message.forms = mergeById( message.forms, - (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, + nested.metadata?.a2uiForm as AiFormSchema | undefined, ); message.reviews = mergeById( message.reviews, - (nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, + nested.metadata?.a2uiReview as AiReviewSchema | undefined, ); message.charts = mergeById( message.charts, - (nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, + nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined, ); const artifacts = nested.metadata?.uiArtifacts; if (Array.isArray(artifacts)) { @@ -152,7 +148,7 @@ export function reduceAiSseMessage( message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); + applyMessagePayload(message, nested); } else if (event === 'reasoning.delta') { message.retrying = null; message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; @@ -162,8 +158,7 @@ export function reduceAiSseMessage( } else if (event === 'model.retrying' && payload.retry) { message.retrying = payload.retry; } else if (event === 'ui.artifact' && payload.artifact) { - // 统一 artifact 事件:后端过渡期仍双发 ui.form/ui.review/ui.chart, - // 前端只消费 ui.artifact,legacy 列表由渲染层从 uiArtifacts 派生。 + // 统一 artifact 事件;legacy 列表由渲染层从 uiArtifacts 派生。 mergeArtifactIntoMessage(message, payload.artifact); } else if (event === 'ui.import_wizard' && payload.wizard) { message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; @@ -185,7 +180,7 @@ export function reduceAiSseMessage( nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); + applyMessagePayload(message, nested); message.retrying = null; } else if (event === 'message.cancelled') { message.id = payload.messageId ?? message.id; From a0829f17ce0365cc10dfc46a2e35c4e504c4e5d0 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 15:05:33 +0800 Subject: [PATCH 15/43] =?UTF-8?q?feat(admin):=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=20UI/UX=20=E6=B7=B1=E5=BA=A6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=94=99=E8=AF=AF/=E5=8A=A0=E8=BD=BD/?= =?UTF-8?q?=E7=A9=BA=E7=8A=B6=E6=80=81=E3=80=81=E8=A1=A8=E5=8D=95=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E3=80=81=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=80=82?= =?UTF-8?q?=E9=85=8D=E4=B8=8E=E6=97=A0=E9=9A=9C=E7=A2=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/App.tsx | 2 + apps/admin/src/components/BackTop.tsx | 40 +++++ .../admin/src/components/NotificationBell.tsx | 15 +- .../src/components/Onboarding/RoleTour.tsx | 162 ------------------ apps/admin/src/components/RefreshButton.tsx | 17 ++ apps/admin/src/components/ScrollToTop.tsx | 16 ++ .../StudentProfileContent/AttachmentsTab.tsx | 2 +- .../StudentProfileContent/EnrollmentsTab.tsx | 2 +- .../StudentProfileContent/ExamScoresTab.tsx | 2 +- .../StudentProfileContent/LearningTab.tsx | 2 +- .../StudentProfileContent/index.tsx | 2 +- .../src/components/ux.integration.test.tsx | 125 ++++++++++++++ apps/admin/src/hooks/useSubmitShortcut.ts | 19 ++ apps/admin/src/index.css | 65 +++++++ apps/admin/src/layouts/MainLayout.tsx | 4 +- apps/admin/src/pages/AiConfig/index.tsx | 6 +- .../Attendance/AttendanceAdminModals.tsx | 6 +- .../Attendance/LessonAttendanceDetail.tsx | 2 +- apps/admin/src/pages/Attendance/admin.tsx | 1 + apps/admin/src/pages/AttendanceDevices.tsx | 2 +- apps/admin/src/pages/Bills/index.tsx | 7 +- .../src/pages/Classes/ClassDetailTabs.tsx | 57 +++--- apps/admin/src/pages/Classes/detail.tsx | 16 +- .../src/pages/ClassroomRentals/index.tsx | 4 +- apps/admin/src/pages/Classrooms/index.tsx | 7 +- .../src/pages/Dashboard/DashboardCharts.ts | 12 +- .../pages/Dashboard/DashboardLazyCards.tsx | 6 +- apps/admin/src/pages/Dashboard/index.tsx | 69 +++++++- .../src/pages/Deposits/DepositModals.tsx | 17 +- .../admin/src/pages/Deposits/DepositTable.tsx | 1 + apps/admin/src/pages/Deposits/index.tsx | 2 + apps/admin/src/pages/Exams/detail.tsx | 30 ++-- .../src/pages/Expenses/ExpenseModals.tsx | 10 +- .../src/pages/IntegrationConfig/index.tsx | 2 +- apps/admin/src/pages/Login/index.tsx | 2 +- apps/admin/src/pages/OperationLogs/index.tsx | 74 ++++---- apps/admin/src/pages/Organizations/index.tsx | 84 ++++----- apps/admin/src/pages/RoomVisual/index.tsx | 25 +-- apps/admin/src/pages/Rooms/RoomColumns.tsx | 1 + apps/admin/src/pages/Rooms/RoomDrawer.tsx | 4 +- apps/admin/src/pages/Rooms/RoomModals.tsx | 10 +- apps/admin/src/pages/Rooms/RoomsToolbar.tsx | 6 + apps/admin/src/pages/Rooms/index.tsx | 2 + .../src/pages/Schedules/ScheduleModals.tsx | 2 +- .../src/pages/Students/StudentColumns.tsx | 1 + .../src/pages/Students/StudentModals.tsx | 4 +- .../src/pages/Students/StudentsTable.tsx | 11 +- .../src/pages/Students/StudentsToolbar.tsx | 6 + apps/admin/src/pages/Students/index.tsx | 2 + .../src/pages/TeacherWorkspace/index.tsx | 12 +- apps/admin/src/pages/Teachers/index.tsx | 3 + apps/admin/src/pages/Wallets/index.tsx | 6 +- apps/admin/vitest.config.ts | 4 +- docs/ux-optimization-summary.md | 80 +++++++++ 54 files changed, 702 insertions(+), 369 deletions(-) create mode 100644 apps/admin/src/components/BackTop.tsx delete mode 100644 apps/admin/src/components/Onboarding/RoleTour.tsx create mode 100644 apps/admin/src/components/RefreshButton.tsx create mode 100644 apps/admin/src/components/ScrollToTop.tsx create mode 100644 apps/admin/src/components/ux.integration.test.tsx create mode 100644 apps/admin/src/hooks/useSubmitShortcut.ts create mode 100644 docs/ux-optimization-summary.md diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index f17a2647..d032245d 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -7,6 +7,7 @@ import zhCN from 'antd/es/locale/zh_CN'; import MainLayout from './layouts/MainLayout'; import PermissionRoute from './components/PermissionRoute'; import DefaultRoute from './components/DefaultRoute'; +import ScrollToTop from './components/ScrollToTop'; import AppMessageBridge from './ui/AppMessageBridge'; import { useUserStore } from './store/user/userStore'; @@ -74,6 +75,7 @@ const App: React.FC = () => { + diff --git a/apps/admin/src/components/BackTop.tsx b/apps/admin/src/components/BackTop.tsx new file mode 100644 index 00000000..59ace443 --- /dev/null +++ b/apps/admin/src/components/BackTop.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { Button, Tooltip } from 'antd'; +import { VerticalAlignTopOutlined } from '@ant-design/icons'; + +/** + * 全局「回到顶部」浮动按钮:长列表滚动超过 400px 后出现。 + * 尊重系统「减少动态效果」偏好,平滑滚动仅在未开启该偏好时使用。 + */ +export const BackTop: React.FC<{ threshold?: number }> = ({ threshold = 400 }) => { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const onScroll = () => setVisible(window.scrollY > threshold); + onScroll(); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, [threshold]); + + if (!visible) return null; + + const scrollToTop = () => { + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' }); + }; + + return ( + + @@ -199,7 +201,12 @@ const NotificationBell: React.FC = () => { placement="bottomRight" > - + ) : null} - + scroll={{ x: 'max-content' }} columns={columns} dataSource={data} rowKey="id" diff --git a/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx b/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx index a6038eef..9bec3847 100644 --- a/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx +++ b/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx @@ -205,7 +205,7 @@ export const EnrollmentsTab: React.FC = > 添加报读记录 - + scroll={{ x: 'max-content' }} columns={columns} dataSource={data} rowKey="id" diff --git a/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx b/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx index ed45f3f0..32de8402 100644 --- a/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx +++ b/apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx @@ -195,7 +195,7 @@ export const ExamScoresTab: React.FC< > 添加考试成绩 - + scroll={{ x: 'max-content' }} columns={columns} dataSource={data} rowKey="id" diff --git a/apps/admin/src/components/StudentProfileContent/LearningTab.tsx b/apps/admin/src/components/StudentProfileContent/LearningTab.tsx index abbed612..c14e8cf4 100644 --- a/apps/admin/src/components/StudentProfileContent/LearningTab.tsx +++ b/apps/admin/src/components/StudentProfileContent/LearningTab.tsx @@ -162,7 +162,7 @@ export const LearningTab: React.FC = ({ > 添加学情记录 - + scroll={{ x: 'max-content' }} columns={columns} dataSource={data} rowKey="id" diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index ac49ff59..7ee66ce7 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -187,7 +187,7 @@ const InlineArchiveSummary: React.FC<{ ); return ( - + | null = null; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const mount = (node: React.ReactNode) => { + const host = document.createElement('div'); + container = host; + document.body.appendChild(host); + root = createRoot(host); + act(() => root?.render(node)); +}; + +afterEach(async () => { + if (root) { + await act(async () => root?.unmount()); + } + container?.remove(); + root = null; + container = null; + vi.restoreAllMocks(); +}); + +describe('UX 组件与交互', () => { + it('RefreshButton 点击触发 onRefresh,loading 时展示加载态', async () => { + const onRefresh = vi.fn(); + mount(); + const button = container?.querySelector('button'); + if (!button) throw new Error('button not rendered'); + act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + await flush(); + expect(onRefresh).toHaveBeenCalledTimes(1); + + mount(); + expect(container?.querySelector('.ant-btn-loading')).toBeTruthy(); + }); + + it('useSubmitShortcut 未激活时不响应 Cmd+Enter', async () => { + const onSubmit = vi.fn(); + const Harness = () => { + useSubmitShortcut(false, onSubmit); + return ; + }; + mount(); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }), + ); + await flush(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('useSubmitShortcut 激活时响应 Cmd/Ctrl+Enter 且阻止默认行为', async () => { + const onSubmit = vi.fn(); + const Harness = () => { + useSubmitShortcut(true, onSubmit); + return ; + }; + mount(); + + const metaEvent = new KeyboardEvent('keydown', { + key: 'Enter', + metaKey: true, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(metaEvent); + expect(metaEvent.defaultPrevented).toBe(true); + + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }), + ); + await flush(); + expect(onSubmit).toHaveBeenCalledTimes(2); + }); + + it('BackTop 超过阈值后出现,点击回到顶部', async () => { + const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + mount(); + await flush(); + const button = container?.querySelector('button'); + expect(button).toBeTruthy(); + if (button) { + act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + } + await flush(); + expect(scrollSpy).toHaveBeenCalledWith(expect.objectContaining({ top: 0 })); + }); + + it('ScrollToTop 在路由切换时把滚动位置复位到顶部', async () => { + const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + const Nav = () => { + const navigate = useNavigate(); + return ( + + ); + }; + mount( + + + + } /> + other} /> + + , + ); + scrollSpy.mockClear(); + + const button = container?.querySelector('button'); + if (!button) throw new Error('button not rendered'); + act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + await flush(); + expect(scrollSpy).toHaveBeenCalledWith(0, 0); + }); +}); diff --git a/apps/admin/src/hooks/useSubmitShortcut.ts b/apps/admin/src/hooks/useSubmitShortcut.ts new file mode 100644 index 00000000..cb365c32 --- /dev/null +++ b/apps/admin/src/hooks/useSubmitShortcut.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; + +/** + * 弹窗/表单内按 Cmd/Ctrl+Enter 触发表单提交。 + * 仅在 active(弹窗打开且非保存中)时监听,避免误触。 + */ +export function useSubmitShortcut(active: boolean, onSubmit: () => void): void { + useEffect(() => { + if (!active) return; + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + onSubmit(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [active, onSubmit]); +} diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css index 8cd00687..9ad23dd2 100644 --- a/apps/admin/src/index.css +++ b/apps/admin/src/index.css @@ -514,3 +514,68 @@ canvas { padding-inline: 0; } } + +/* ─── 全局无障碍与质感增强 ─── */ +html { + scroll-behavior: smooth; +} + +::selection { + background: rgba(0, 122, 255, 0.18); +} + +/* 键盘导航焦点可见(鼠标点击不显示,符合 WCAG 2.4.7) */ +:focus-visible { + outline: 2px solid #007aff; + outline-offset: 2px; +} + +/* 细滚动条(macOS/Chromium),降低大面积滚动条对视觉的干扰 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.16); + border: 2px solid transparent; + border-radius: 8px; + background-clip: content-box; +} + +::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.28); +} + +/* 尊重系统「减少动态效果」偏好 */ +/* 窄屏下压缩路由标签尺寸,避免横向裁切 */ +@media (max-width: 575px) { + .route-dock { + padding: 6px 8px; + } + + .route-dock .ant-tabs-tab { + min-width: 88px; + max-width: 160px; + padding: 0 8px 0 10px !important; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 6b33e960..5c7f9073 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -40,7 +40,7 @@ import { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/ import NotificationBell from '../components/NotificationBell'; import RouteDock from '../components/RouteDock'; import RouteKeeper from '../components/RouteKeeper'; -import RoleTour from '../components/Onboarding/RoleTour'; +import BackTop from '../components/BackTop'; import { buildMenu, type AppMenuItem } from '../auth/menu-policy'; const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer')); @@ -432,7 +432,7 @@ const MainLayout: React.FC = () => { /> )} - + ); }; diff --git a/apps/admin/src/pages/AiConfig/index.tsx b/apps/admin/src/pages/AiConfig/index.tsx index d443e720..c2dc4ce5 100644 --- a/apps/admin/src/pages/AiConfig/index.tsx +++ b/apps/admin/src/pages/AiConfig/index.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { aiConfigEnvelopeSchema } from '../../api/schemas'; -import { App, Alert, Button, Form, Space, Spin, Steps, Tag } from 'antd'; +import {App, Alert, Button, Form, Space, Steps, Tag, Skeleton} from 'antd'; import api from '../../api'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; @@ -332,8 +332,8 @@ const AiConfigPage: React.FC = () => { if (loading) { return ( -
- +
+
); } diff --git a/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx b/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx index 3af0f747..b107e67a 100644 --- a/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx +++ b/apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx @@ -27,7 +27,8 @@ export const PeriodConfigModal: React.FC<{ onOk: () => void; onCancel: () => void; onReset: () => void; -}> = ({ open, form, onOk, onCancel, onReset }) => { + confirmLoading?: boolean; +}> = ({ open, form, onOk, onCancel, onReset, confirmLoading }) => { return ( ( <>
+ + + + + ))} + + + + + + ); + } + + if (isError) { + return ( + void refetch()} + /> + ); + } return (
@@ -205,12 +245,25 @@ const DashboardPage: React.FC = () => { aria-label="选择日期范围" value={[dayjs(period[0]), dayjs(period[1])]} onChange={(dates) => { - if (dates?.[0] && dates?.[1]) + if (dates?.[0] && dates?.[1]) { setPeriod([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]); + setPartialAlertClosed(false); + } }} />
+ {fetchResult.partialFailures > 0 && !partialAlertClosed ? ( + setPartialAlertClosed(true)} + message={`有 ${fetchResult.partialFailures} 项数据加载失败,其余数据已正常显示`} + style={{ marginBottom: 16 }} + /> + ) : null} + {/* 待办与异常 */} = ({ onOpenInstallment, onSelectEligible, }) => { + useSubmitShortcut(batchModal && !saving, onBatchCreate); + useSubmitShortcut(createModal && !saving, onCreate); + useSubmitShortcut(!!refundModal && !saving, onRefund); + useSubmitShortcut(!!installmentModal && !saving, onAddInstallment); return ( <> = ({ okButtonProps={{ disabled: effectiveSelectedEligibleIds.length === 0 }} width={760} > - + = ({ )} -
= ({ okText="确认" confirmLoading={saving} > - + = ({ okText="确认退还" confirmLoading={saving} > - +
当前可用押金: ¥{Number(refundModal?.amount || 0).toFixed(2)}
@@ -311,7 +316,7 @@ export const DepositModals: React.FC = ({ {detailModal.installments && detailModal.installments.length > 0 ? ( -
= ({ okText="确认" confirmLoading={saving} > - + diff --git a/apps/admin/src/pages/Deposits/DepositTable.tsx b/apps/admin/src/pages/Deposits/DepositTable.tsx index 6541dde7..d20034a5 100644 --- a/apps/admin/src/pages/Deposits/DepositTable.tsx +++ b/apps/admin/src/pages/Deposits/DepositTable.tsx @@ -63,6 +63,7 @@ export const DepositTable: React.FC = ({ { title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' }, { title: '操作', + fixed: 'right' as const, width: 240, render: (_: unknown, record: any) => { const hasDeposit = typeof record.id === 'number'; diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 989bbe9b..f2832ee8 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -11,6 +11,7 @@ import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; +import { RefreshButton } from '../../components/RefreshButton'; import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option'; import { usePermission } from '../../hooks/usePermission'; import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query'; @@ -426,6 +427,7 @@ const DepositsPage: React.FC = () => { /> + void refetch()} /> } diff --git a/apps/admin/src/pages/Exams/detail.tsx b/apps/admin/src/pages/Exams/detail.tsx index 314d9691..35234b23 100644 --- a/apps/admin/src/pages/Exams/detail.tsx +++ b/apps/admin/src/pages/Exams/detail.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useMemo } from 'react'; -import { Alert, Button, Card, Descriptions, Empty, Space, Spin, Table, Tag, Tooltip } from 'antd'; +import {Alert, Button, Card, Descriptions, Empty, Space, Table, Tag, Tooltip, Skeleton} from 'antd'; +import { QueryErrorState } from '../../components/QueryState'; import type { ColumnsType } from 'antd/es/table'; import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons'; import { useNavigate, useParams } from 'react-router'; @@ -15,7 +16,6 @@ import { examDetailSchema } from '../../api/schemas'; import { usePermission } from '../../hooks/usePermission'; import type { ExamItem } from './types'; import './style.css'; -import { getErrorMessage } from '../../utils/error'; interface ScoreRow { id: number; @@ -56,19 +56,10 @@ const ExamDetailPage: React.FC = () => { const { id } = useParams(); const navigate = useNavigate(); - const { data: detail, isLoading, isFetching } = useQuery({ + const { data: detail, isLoading, isFetching, isError, refetch } = useQuery({ queryKey: ['exams', 'detail', id], - queryFn: async () => { - try { - return validateResponse( - examDetailSchema, - await api.get(`/exams/${id}`), - ); - } catch (error) { - message.error(getErrorMessage(error, '加载考试失败')); - return null; - } - }, + queryFn: async () => + validateResponse(examDetailSchema, await api.get(`/exams/${id}`)), }); const loading = isLoading || isFetching; @@ -145,9 +136,18 @@ const ExamDetailPage: React.FC = () => { if (loading && !detail) return (
- +
); + if (isError) { + return ( + void refetch()} + /> + ); + } if (!detail) return ; const average = detail.scores.find((row) => row.classAvg !== null)?.classAvg ?? null; diff --git a/apps/admin/src/pages/Expenses/ExpenseModals.tsx b/apps/admin/src/pages/Expenses/ExpenseModals.tsx index efdc22e1..0c46f937 100644 --- a/apps/admin/src/pages/Expenses/ExpenseModals.tsx +++ b/apps/admin/src/pages/Expenses/ExpenseModals.tsx @@ -9,6 +9,7 @@ import { Select, } from 'antd'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; +import { useSubmitShortcut } from '../../hooks/useSubmitShortcut'; const { RangePicker } = DatePicker; @@ -22,6 +23,7 @@ export const RoomExpenseModal: React.FC<{ onOk: () => void; onCancel: () => void; }> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => { + useSubmitShortcut(open && !saving, () => onOk?.()); const roomExpenseGuard = useDirtyGuard(form); // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准 useEffect(() => { @@ -37,7 +39,7 @@ export const RoomExpenseModal: React.FC<{ okText={editing ? '保存' : '确认录入'} confirmLoading={saving} > - + void; onCancel: () => void; }> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => { + useSubmitShortcut(open && !saving, () => onOk?.()); const personalExpenseGuard = useDirtyGuard(form); // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准 useEffect(() => { @@ -151,7 +155,7 @@ export const PersonalExpenseModal: React.FC<{ okText={editing ? '保存' : '确认录入'} confirmLoading={saving} > - + } placeholder="用户名" autoComplete="username" /> + } placeholder="用户名" autoComplete="username" autoFocus /> { data: fetchResult = { data: [], total: 0 }, isLoading, isFetching, + isError, + refetch, } = useQuery<{ data: any[]; total: number }>({ queryKey: ['operation-logs', page, pageSize, filterModule, dateRange], queryFn: async () => { - try { - const params: any = { page, pageSize }; - if (filterModule) params.module = filterModule; - if (dateRange) { - params.startDate = dateRange[0]; - params.endDate = dateRange[1]; - } - return validateResponse<{ data: any[]; total: number }>( - operationLogsSchema, - await api.get('/operation-logs', { params }), - ); - } catch (e: unknown) { - message.error(getErrorMessage(e, '加载失败,请稍后重试')); - return { data: [], total: 0 }; + const params: any = { page, pageSize }; + if (filterModule) params.module = filterModule; + if (dateRange) { + params.startDate = dateRange[0]; + params.endDate = dateRange[1]; } + return validateResponse<{ data: any[]; total: number }>( + operationLogsSchema, + await api.get('/operation-logs', { params }), + ); }, }); const data = fetchResult.data; @@ -161,25 +157,33 @@ const OperationLogsPage: React.FC = () => { />
-
{ - setPage(nextPage); - setPageSize(nextPageSize); - }, - showTotal: (t) => `共 ${t} 条`, - }} - /> + {isError ? ( + void refetch()} + /> + ) : ( +
{ + setPage(nextPage); + setPageSize(nextPageSize); + }, + showTotal: (t) => `共 ${t} 条`, + }} + /> + )} ); }; diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index 28479876..5f678984 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -7,12 +7,13 @@ import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; +import { RefreshButton } from '../../components/RefreshButton'; import { usePermission } from '../../hooks/usePermission'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { organizationsSchema } from '../../api/schemas'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; -import { QueryEmpty } from '../../components/QueryState'; +import { QueryEmpty, QueryErrorState } from '../../components/QueryState'; const PRESET_COLORS = [ '#ff7875', @@ -59,21 +60,15 @@ const OrganizationsPage: React.FC = () => { const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(); - const { data = [], isLoading, isFetching } = useQuery({ + const { data = [], isLoading, isFetching, isError, refetch } = useQuery({ queryKey: ['organizations'], - queryFn: async () => { - try { - return validateResponse( - organizationsSchema, - await api.get('/organizations', { - params: { includeArchived: true }, - }), - ); - } catch (error: any) { - message.error(error?.message || '机构数据加载失败'); - return []; - } - }, + queryFn: async () => + validateResponse( + organizationsSchema, + await api.get('/organizations', { + params: { includeArchived: true }, + }), + ), }); const loading = isLoading || isFetching; @@ -385,6 +380,7 @@ const OrganizationsPage: React.FC = () => { ]} /> + void refetch()} /> { 添加机构 -
, onClick: () => openEditor() } - : undefined - } - /> - ), - }} - scroll={{ x: 1100 }} - pagination={{ - defaultPageSize: 20, - showSizeChanger: true, - pageSizeOptions: [20, 50, 100], - showTotal: (total) => `共 ${total} 个机构`, - }} - /> + {isError ? ( + void refetch()} + /> + ) : ( +
, onClick: () => openEditor() } + : undefined + } + /> + ), + }} + scroll={{ x: 1100 }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + showTotal: (total) => `共 ${total} 个机构`, + }} + /> + )} { ); } - if (!data) return ; + if (!data) + return ( +
+ +
+ ); const rooms = data.rooms.filter((r: any) => { if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false; diff --git a/apps/admin/src/pages/Rooms/RoomColumns.tsx b/apps/admin/src/pages/Rooms/RoomColumns.tsx index 85f8b68a..71d36bb6 100644 --- a/apps/admin/src/pages/Rooms/RoomColumns.tsx +++ b/apps/admin/src/pages/Rooms/RoomColumns.tsx @@ -253,6 +253,7 @@ function buildRoomActionColumn(ctx: RoomColumnContext) { } = ctx; return { title: '操作', + fixed: 'right' as const, width: 220, render: (_: unknown, record: unknown) => { const r = record as { status?: string; id: number; roomNumber?: string }; diff --git a/apps/admin/src/pages/Rooms/RoomDrawer.tsx b/apps/admin/src/pages/Rooms/RoomDrawer.tsx index 9582a7ce..e8599802 100644 --- a/apps/admin/src/pages/Rooms/RoomDrawer.tsx +++ b/apps/admin/src/pages/Rooms/RoomDrawer.tsx @@ -211,7 +211,7 @@ export const RoomDrawer: React.FC = ({ ) : null} -
= ({ ) : null} -
void; onCancel: () => void; }> = ({ open, editing, saving, form, onOk, onCancel }) => { + useSubmitShortcut(open && !saving, () => onOk?.()); return ( - + void; onCancel: () => void; }> = ({ open, editing, saving, form, onOk, onCancel }) => { + useSubmitShortcut(open && !saving, () => onOk?.()); return ( - + @@ -138,6 +141,7 @@ export const LockerModal: React.FC<{ onOk?: () => void; onCancel: () => void; }> = ({ open, editing, saving, form, onOk, onCancel }) => { + useSubmitShortcut(open && !saving, () => onOk?.()); return ( -
+
diff --git a/apps/admin/src/pages/Rooms/RoomsToolbar.tsx b/apps/admin/src/pages/Rooms/RoomsToolbar.tsx index 9dd66457..b9eaa004 100644 --- a/apps/admin/src/pages/Rooms/RoomsToolbar.tsx +++ b/apps/admin/src/pages/Rooms/RoomsToolbar.tsx @@ -12,6 +12,7 @@ import { UploadOutlined, } from '@ant-design/icons'; import PermissionButton from '../../components/PermissionButton'; +import { RefreshButton } from '../../components/RefreshButton'; export interface RoomsToolbarProps { onSearch: (value: string) => void; @@ -39,6 +40,8 @@ export interface RoomsToolbarProps { onExport: () => void; templateLoading?: boolean; exportLoading?: boolean; + refreshLoading?: boolean; + onRefresh?: () => void; } export const RoomsToolbar: React.FC = ({ @@ -67,6 +70,8 @@ export const RoomsToolbar: React.FC = ({ onExport, templateLoading, exportLoading, + refreshLoading, + onRefresh, }) => { return (
@@ -115,6 +120,7 @@ export const RoomsToolbar: React.FC = ({ + {onRefresh ? : null} {showArchived && canEditRooms ? ( <> { templateLoading={templateDownloading} onExport={handleExport} exportLoading={exportDownloading} + refreshLoading={isFetching} + onRefresh={() => void refetch()} /> {isError ? ( diff --git a/apps/admin/src/pages/Schedules/ScheduleModals.tsx b/apps/admin/src/pages/Schedules/ScheduleModals.tsx index 26cd9ae5..29624d62 100644 --- a/apps/admin/src/pages/Schedules/ScheduleModals.tsx +++ b/apps/admin/src/pages/Schedules/ScheduleModals.tsx @@ -109,7 +109,7 @@ export const ScheduleModal: React.FC = ({ destroyOnHidden > {mode !== 'detail' ? ( -
+ diff --git a/apps/admin/src/pages/Students/StudentsTable.tsx b/apps/admin/src/pages/Students/StudentsTable.tsx index 62424261..feb02205 100644 --- a/apps/admin/src/pages/Students/StudentsTable.tsx +++ b/apps/admin/src/pages/Students/StudentsTable.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Alert, Button, Card, Col, Descriptions, Row, Table, Tag } from 'antd'; +import { Alert, Button, Card, Col, Descriptions, Row, Spin, Table, Tag } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import api from '../../api'; import { QueryEmpty } from '../../components/QueryState'; @@ -102,7 +102,14 @@ export const StudentsTable: React.FC<{ rowExpandable: () => true, expandedRowRender: (record) => { const enrollments = enrollmentData[record.id]; - if (!enrollments) return null; + if (!enrollments) { + return ( +
+ + 正在加载班型对比… +
+ ); + } if (enrollments.length < 2) { return (
diff --git a/apps/admin/src/pages/Students/StudentsToolbar.tsx b/apps/admin/src/pages/Students/StudentsToolbar.tsx index 3f2af74c..a078234f 100644 --- a/apps/admin/src/pages/Students/StudentsToolbar.tsx +++ b/apps/admin/src/pages/Students/StudentsToolbar.tsx @@ -22,6 +22,7 @@ import { UploadOutlined, } from '@ant-design/icons'; import PermissionButton from '../../components/PermissionButton'; +import { RefreshButton } from '../../components/RefreshButton'; import { statusMap } from './StudentColumns'; export interface StudentsToolbarProps { @@ -60,6 +61,8 @@ export interface StudentsToolbarProps { onExport: () => void; templateLoading?: boolean; exportLoading?: boolean; + refreshLoading?: boolean; + onRefresh?: () => void; } export const StudentsToolbar: React.FC = ({ @@ -98,6 +101,8 @@ export const StudentsToolbar: React.FC = ({ onExport, templateLoading, exportLoading, + refreshLoading, + onRefresh, }) => { const { modal } = App.useApp(); return ( @@ -171,6 +176,7 @@ export const StudentsToolbar: React.FC = ({ + {onRefresh ? : null} {showArchived && canEditStudent ? ( <> { templateLoading={templateDownloading} onExport={handleExport} exportLoading={exportDownloading} + refreshLoading={isFetching} + onRefresh={() => void refetch()} /> {nextStepHint === 'class' && ( { if (loading) { return ( -
- +
+
); } @@ -157,7 +157,7 @@ const TeacherWorkspacePage: React.FC = () => { key: 'classes', label: `我的班级 (${data?.assignedClasses.length || 0})`, children: data?.assignedClasses.length ? ( - + scroll={{ x: 'max-content' }} columns={classColumns} dataSource={data.assignedClasses} rowKey="classId" @@ -177,7 +177,7 @@ const TeacherWorkspacePage: React.FC = () => { key: 'schedule', label: `今日课程 (${data?.todaySchedules.length || 0})`, children: data?.todaySchedules.length ? ( - + scroll={{ x: 'max-content' }} columns={scheduleColumns} dataSource={data.todaySchedules} rowKey="id" @@ -203,7 +203,7 @@ const TeacherWorkspacePage: React.FC = () => { key: 'students', label: `我的学生 (${data?.myStudents.length || 0})`, children: data?.myStudents.length ? ( - + scroll={{ x: 'max-content' }} columns={studentColumns} dataSource={data.myStudents} rowKey="studentId" diff --git a/apps/admin/src/pages/Teachers/index.tsx b/apps/admin/src/pages/Teachers/index.tsx index a7bc68a2..11ed2659 100644 --- a/apps/admin/src/pages/Teachers/index.tsx +++ b/apps/admin/src/pages/Teachers/index.tsx @@ -8,6 +8,7 @@ import { EditOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { RefreshButton } from '../../components/RefreshButton'; import EditableCell from '../../components/EditableCell'; import PermissionButton from '../../components/PermissionButton'; import { usePermission } from '../../hooks/usePermission'; @@ -211,6 +212,7 @@ const TeachersPage: React.FC = () => { { title: '操作', key: 'actions', + fixed: 'right' as const, width: 100, render: (_: unknown, r: TeacherRow) => ( { }} style={{ width: 220 }} /> + void refetch()} /> {isError ? ( { }, { title: '操作', + fixed: 'right' as const, + width: 140, render: (_: unknown, row: WalletRow) => ( { onRetry={() => void refetch()} /> ) : ( -
}} @@ -425,7 +427,7 @@ const WalletsPage: React.FC = () => { onRetry={() => void refetchTransactions()} /> ) : ( -
目标:深度优化用户操作的 UI/UX(表单、列表、反馈、导航、加载态、错误态、空状态、移动端适配等), +> 保持现有 Ant Design 体系与功能不变,最终通过类型检查、lint 与测试验证。 +> 本文件按类别汇总已落地改动与验证记录,供 review 使用。 + +## 一、错误态(失败不再伪装成"空数据/0 值",均带重试) + +- 工作台 Dashboard:全部接口失败时显示 `QueryErrorState` + 重试(此前静默显示 0 值)。 +- 考试详情页:失败显示错误态 + 重试(此前空白页)。 +- 机构列表 / 操作日志:失败显示错误态 + 重试(此前弹 toast 后渲染空表)。 +- 部分失败反馈:Dashboard 部分接口失败改为页面内可关闭 `Alert`(此前每次切换日期弹 toast)。 + +## 二、加载态 + +- 6 个页面骨架屏替代整页 Spin:工作台(统计卡片骨架)、教师工作台、班级详情、考试详情、宿舍可视化、AI 配置。 +- 学生表展开行(多班型对比)加载中显示 Spin + 提示,不再空白。 +- 班级花名册导出按钮 loading(try/finally)。 + +## 三、空状态 + +- 复用统一 `QueryEmpty`(描述 + 主操作按钮引导):学生/宿舍/押金/账单/教室/教师/钱包/机构等列表页已覆盖。 + +## 四、表单 + +- 新增 `useSubmitShortcut`:Cmd/Ctrl+Enter 提交,覆盖 12+ 弹窗(学生、宿舍/床位/柜子、押金×4、费用×3、教室、租赁、班级添加学员/教师)。 +- 15 个表单统一 `scrollToFirstError`:校验失败自动滚动到首个错误字段。 +- 防重复提交:考勤时段配置、班级添加学员/教师补齐 `confirmLoading`(其余弹窗已具备)。 + +## 五、列表 + +- 表格横向滚动:10+ 个缺失 `scroll.x` 的表格补齐(学生资料 4 Tab、考勤明细、考勤机、钱包、宿舍抽屉、教师工作台、押金弹窗等)。 +- 移动端操作列固定:学生/宿舍/押金/账单/教室/教师/钱包 7 个主列表操作列 `fixed: 'right'`。 +- 手动刷新入口:新增 `RefreshButton`,接入 8 个列表页(学生/宿舍/押金/教室/账单/教师/机构;钱包原有)。 +- 分页/跨页勾选/已选提示均已具备,未改动。 + +## 六、反馈 + +- 通知铃铛:全部已读失败给出错误提示;无未读时按钮置灰;铃铛改为可聚焦按钮 + `aria-label`。 +- 统一消息桥(App context)已具备;导出/删除/保存反馈已具备。 + +## 七、导航 + +- 新增 `ScrollToTop`:路由切换后滚动复位到顶部。 +- 新增全局 `BackTop`:长列表滚动超过 400px 后浮现"回到顶部"按钮(尊重 reduced-motion)。 +- 登录页用户名自动聚焦。 +- 路由标签(RouteDock)窄屏压缩尺寸,避免横向裁切。 + +## 八、移动端适配 + +- 表格横向滚动 + 操作列固定(见"列表")。 +- `Descriptions` 响应式列数:班级详情、账单详情×2、学生档案、集成配置(此前固定列数在窄屏过挤)。 +- 内容区 padding 已按 breakpoint 响应式(MainLayout 原有)。 + +## 九、无障碍与质感(全局 CSS) + +- `:focus-visible` 品牌蓝焦点环(键盘导航可见)。 +- `@media (prefers-reduced-motion: reduce)` 关闭动画/平滑滚动。 +- `::selection` 品牌色;WebKit 细滚动条;`html` 平滑滚动。 + +## 新增文件 + +- `components/RefreshButton.tsx`、`components/ScrollToTop.tsx`、`components/BackTop.tsx` +- `hooks/useSubmitShortcut.ts` +- `components/ux.integration.test.tsx`(5 个浏览器用例:RefreshButton / useSubmitShortcut 激活与未激活 / ScrollToTop / BackTop) + +## 验证记录 + +| 检查 | 结果 | +| --- | --- | +| typecheck(admin + server) | ✅ | +| lint(oxlint + eslint) | ✅ 0 warning / 0 error | +| admin vitest(浏览器) | ✅ 31 文件 / 166 用例 | +| server jest | ✅ 191 用例 | +| aislop scan --changes | ✅ 100/100 | + +## 未做/后续(可选) + +- 运行时视觉走查:需要 MySQL + 后端 + 前端环境(当前本地未运行),用于最终逐页截图核对。 +- AI 助手抽屉:已具备响应式/空态/取消/技能锁定,`submit` 已有请求中防护,未做额外改动。 From f96c1c26c3acecad2fcf3ee4e046749001a6f1b6 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 15:05:42 +0800 Subject: [PATCH 16/43] =?UTF-8?q?fix(server):=20=E8=80=83=E5=8B=A4?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=BB=93=E7=AE=97=E6=94=AF=E6=8C=81=E7=A9=BA?= =?UTF-8?q?=E7=8F=AD=E7=BA=A7=E7=9B=B4=E6=8E=A5=E5=AE=8C=E6=88=90=EF=BC=8C?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E8=87=AA=E5=8A=A8=E5=8C=B9=E9=85=8D=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance-settlement.service.spec.ts | 52 ++++++++++++ .../attendance-settlement.service.ts | 82 ++++++++++++++++++- .../src/attendance/attendance.service.spec.ts | 42 ++++++++-- 3 files changed, 168 insertions(+), 8 deletions(-) diff --git a/apps/server/src/attendance/attendance-settlement.service.spec.ts b/apps/server/src/attendance/attendance-settlement.service.spec.ts index eaa0aa8a..aace41d8 100644 --- a/apps/server/src/attendance/attendance-settlement.service.spec.ts +++ b/apps/server/src/attendance/attendance-settlement.service.spec.ts @@ -1,4 +1,5 @@ import { AttendanceSettlementService } from './attendance-settlement.service'; +import { BadRequestException } from '@nestjs/common'; const schedule = { id: 2, @@ -17,6 +18,9 @@ const createService = () => { const scheduleRepo = { find: jest.fn() }; const sessionRepo = { find: jest.fn(), + findOne: jest.fn(), + create: jest.fn().mockImplementation((data) => data), + save: jest.fn().mockImplementation((entity) => Promise.resolve({ id: 99, ...entity })), update: jest.fn().mockResolvedValue({ affected: 1 }), }; const attendanceService = { @@ -171,6 +175,54 @@ describe('AttendanceSettlementService', () => { ); }); + it('finalizes an empty completed session when the class has no students', async () => { + const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + scheduleRepo.find.mockResolvedValue([schedule]); + sessionRepo.find.mockResolvedValue([]); + sessionRepo.findOne.mockResolvedValue(null); + attendanceService.createLessonAttendanceFromDingTalk.mockRejectedValue( + new BadRequestException('该班级暂无在读学生'), + ); + + await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00')); + + expect(sessionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + scheduleId: 2, + classId: 8, + lessonDate: '2026-07-13', + status: 'completed', + completedBy: 21, + }), + ); + expect(importService.importFromDingTalk).not.toHaveBeenCalled(); + }); + + it('completes an in-progress session when the class has no students', async () => { + const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + scheduleRepo.find.mockResolvedValue([]); + sessionRepo.find.mockResolvedValue([ + { + id: 90, + scheduleId: 2, + lessonDate: '2026-07-13', + status: 'in_progress', + schedule, + }, + ]); + attendanceService.getTeacherClassDingUserIds.mockRejectedValue( + new BadRequestException('该班级暂无在读学生'), + ); + + await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00')); + + expect(sessionRepo.update).toHaveBeenCalledWith( + { id: 90, status: 'settling' }, + expect.objectContaining({ status: 'completed', completedBy: 21 }), + ); + expect(importService.importFromDingTalk).not.toHaveBeenCalled(); + }); + it('does not finalize when an import reports partial errors', async () => { const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index a3fa8f11..c55086ee 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; @@ -150,13 +150,91 @@ export class AttendanceSettlementService { true, ); } catch (error: unknown) { - if (session) await this.sessionRepo.update({ id: session.id, status: 'settling' }, { status: 'in_progress' }); + if (this.isNoActiveStudentsError(error)) { + try { + await this.finalizeEmptyLesson(schedule, lessonDate, session); + this.logger.log(`课程${schedule.id} ${lessonDate}班级无在读学生,已直接完成结算`); + } catch (finalizeError: unknown) { + this.logger.error( + `课程${schedule.id} ${lessonDate}空班级完成结算落库失败: ${finalizeError instanceof Error ? finalizeError.message : String(finalizeError)}`, + ); + if (session) { + await this.sessionRepo.update( + { id: session.id, status: 'settling' }, + { status: 'in_progress' }, + ); + } + } + return; + } + if (session) { + await this.sessionRepo.update({ id: session.id, status: 'settling' }, { status: 'in_progress' }); + } this.logger.error( `课程${schedule.id} ${lessonDate}自动结算失败: ${error instanceof Error ? error.message : String(error)}`, ); } } + private isNoActiveStudentsError(error: unknown): boolean { + return ( + error instanceof BadRequestException && + error.message.includes('该班级暂无在读学生') + ); + } + + private async finalizeEmptyLesson( + schedule: ClassSchedule, + lessonDate: string, + session?: AttendanceSession, + ): Promise { + const completedAt = new Date(); + const completedBy = schedule.teacherId; + if (session) { + await this.sessionRepo.update( + { id: session.id, status: 'settling' }, + { status: 'completed', completedBy, completedAt }, + ); + return; + } + + const existing = await this.sessionRepo.findOne({ + where: { scheduleId: schedule.id, lessonDate }, + }); + if (existing) { + if (existing.status !== 'completed') { + await this.sessionRepo.update( + { id: existing.id }, + { status: 'completed', completedBy, completedAt }, + ); + } + return; + } + + try { + await this.sessionRepo.save( + this.sessionRepo.create({ + scheduleId: schedule.id, + classId: schedule.classId!, + lessonDate, + status: 'completed', + startedBy: completedBy, + startedAt: completedAt, + completedBy, + completedAt, + }), + ); + } catch (error: unknown) { + const code = (error as Record).code; + const errno = (error as Record).errno; + if (code !== 'ER_DUP_ENTRY' && errno !== 1062) throw error; + await this.sessionRepo.update( + { scheduleId: schedule.id, lessonDate, status: 'in_progress' }, + { status: 'completed', completedBy, completedAt }, + ); + } + } + private getEndedOccurrenceDate( schedule: ClassSchedule, clock: { weekDay: number; minutes: number }, diff --git a/apps/server/src/attendance/attendance.service.spec.ts b/apps/server/src/attendance/attendance.service.spec.ts index c00e7ae8..d8923e45 100644 --- a/apps/server/src/attendance/attendance.service.spec.ts +++ b/apps/server/src/attendance/attendance.service.spec.ts @@ -19,6 +19,8 @@ import { BatchCreateAttendanceDto } from './dto/attendance.dto'; describe('AttendanceService — batchCreate', () => { let service: AttendanceService; + let mockDingRepo: { find: jest.Mock; save: jest.Mock }; + let mockStudentDingMappingRepo: { find: jest.Mock }; const savedRecords: AttendanceRecord[] = []; @@ -37,13 +39,16 @@ describe('AttendanceService — batchCreate', () => { }), }; - const mockDingRepo = {}; + mockDingRepo = { + find: jest.fn().mockResolvedValue([]), + save: jest.fn().mockImplementation((entity: DingAttendanceRaw) => Promise.resolve(entity)), + }; const mockClassRepo = { find: jest.fn().mockResolvedValue([]) }; const mockStudentRepo = { find: jest.fn().mockResolvedValue([]) }; // Reserved for future tests (auto-match, schedule-based attendance, etc.) const mockScheduleRepo = { find: jest.fn().mockResolvedValue([]) }; const mockClassStudentRepo = { find: jest.fn().mockResolvedValue([]) }; - const mockStudentDingMappingRepo = { find: jest.fn().mockResolvedValue([]) }; + mockStudentDingMappingRepo = { find: jest.fn().mockResolvedValue([]) }; const mockAttendanceDeviceRepo = { find: jest.fn().mockResolvedValue([]) }; const module: TestingModule = await Test.createTestingModule({ @@ -132,10 +137,35 @@ describe('AttendanceService — batchCreate', () => { await expect(service.batchCreate(dto)).rejects.toThrow(BadRequestException); }); - it.skip('autoMatchDingRecords with StudentDingMapping chain', async () => { - // TODO: match dingtalk raw records to students via StudentDingMapping lookup, - // then to class schedules → ClassStudent association, producing attendance records. - // Requires mock setup for StudentDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos. + it('autoMatchDingRecords 通过 StudentDingMapping 匹配未匹配的钉钉原始记录', async () => { + const rawRecords = [ + { + id: 1, + dingUserId: 'ding-1', + userName: '张三', + matchStatus: 'unmatched', + matchedStudentId: null, + }, + { + id: 2, + dingUserId: 'ding-unknown', + userName: '李四', + matchStatus: 'unmatched', + matchedStudentId: null, + }, + ] as DingAttendanceRaw[]; + mockDingRepo.find.mockResolvedValue(rawRecords); + mockStudentDingMappingRepo.find.mockResolvedValue([ + { studentId: 10, dingUserId: 'ding-1' }, + ]); + + const result = await service.autoMatchDingRecords(); + + expect(result).toEqual({ matched: 1, total: 2 }); + expect(mockDingRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ id: 1, matchedStudentId: 10, matchStatus: 'matched' }), + ); + expect(rawRecords[1]).toMatchObject({ matchStatus: 'unmatched', matchedStudentId: null }); }); }); From f160256865719ee7977bfe13b9753fbe7781c6e8 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 15:05:52 +0800 Subject: [PATCH 17/43] =?UTF-8?q?chore(server):=20=E7=A7=BB=E9=99=A4=20e2e?= =?UTF-8?q?=20=E6=B5=8B=E8=AF=95=E9=85=8D=E7=BD=AE=E4=B8=8E=20supertest=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/README.md | 3 - apps/server/package.json | 3 - apps/server/test/jest-e2e.json | 9 -- package-lock.json | 162 --------------------------------- 4 files changed, 177 deletions(-) delete mode 100644 apps/server/test/jest-e2e.json diff --git a/apps/server/README.md b/apps/server/README.md index 8f0f65f7..7978bdbf 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -50,9 +50,6 @@ $ npm run start:prod # unit tests $ npm run test -# e2e tests -$ npm run test:e2e - # test coverage $ npm run test:cov ``` diff --git a/apps/server/package.json b/apps/server/package.json index c3ba960f..7fc9b331 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -21,7 +21,6 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json", "migration:generate": "ts-node -r tsconfig-paths/register -P tsconfig.json ../../node_modules/typeorm/cli.js -d datasource.ts migration:generate", "migration:create": "ts-node -r tsconfig-paths/register -P tsconfig.json ../../node_modules/typeorm/cli.js -d datasource.ts migration:create", "migration:run": "ts-node -r tsconfig-paths/register -P tsconfig.json ../../node_modules/typeorm/cli.js -d datasource.ts migration:run", @@ -74,12 +73,10 @@ "@types/jest": "^30.0.0", "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", - "@types/supertest": "^7.0.0", "cross-env": "^10.1.0", "eslint": "^9.18.0", "globals": "^17.0.0", "jest": "^30.0.0", - "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", diff --git a/apps/server/test/jest-e2e.json b/apps/server/test/jest-e2e.json deleted file mode 100644 index e9d912f3..00000000 --- a/apps/server/test/jest-e2e.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - } -} diff --git a/package-lock.json b/package-lock.json index fd2c6980..b310c3a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,12 +136,10 @@ "@types/jest": "^30.0.0", "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", - "@types/supertest": "^7.0.0", "cross-env": "^10.1.0", "eslint": "^9.18.0", "globals": "^17.0.0", "jest": "^30.0.0", - "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", @@ -4503,16 +4501,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" - } - }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", @@ -5869,13 +5857,6 @@ "@types/node": "*" } }, - "node_modules/@types/cookiejar": { - "version": "2.1.5", - "resolved": "https://registry.npmmirror.com/@types/cookiejar/-/cookiejar-2.1.5.tgz", - "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -6277,13 +6258,6 @@ "integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==", "license": "MIT" }, - "node_modules/@types/methods": { - "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/@types/methods/-/methods-1.1.4.tgz", - "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", @@ -6414,30 +6388,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/superagent": { - "version": "8.1.10", - "resolved": "https://registry.npmmirror.com/@types/superagent/-/superagent-8.1.10.tgz", - "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cookiejar": "^2.1.5", - "@types/methods": "^1.1.4", - "@types/node": "*", - "form-data": "^4.0.0" - } - }, - "node_modules/@types/supertest": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/@types/supertest/-/supertest-7.2.0.tgz", - "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/methods": "^1.1.4", - "@types/superagent": "^8.1.0" - } - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -7990,13 +7940,6 @@ "dev": true, "license": "MIT" }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmmirror.com/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "license": "MIT" - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", @@ -9016,16 +8959,6 @@ "node": ">= 6" } }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz", @@ -9175,13 +9108,6 @@ "node": ">=6.6.0" } }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmmirror.com/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "license": "MIT" - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", @@ -10082,17 +10008,6 @@ "node": ">=8" } }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmmirror.com/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, - "license": "ISC", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, "node_modules/dfa": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/dfa/-/dfa-1.2.0.tgz", @@ -11252,24 +11167,6 @@ "node": ">=0.4.x" } }, - "node_modules/formidable": { - "version": "3.5.4", - "resolved": "https://registry.npmmirror.com/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", @@ -14549,29 +14446,6 @@ "uuid": "dist-node/bin/uuid" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", @@ -17444,42 +17318,6 @@ "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, - "node_modules/superagent": { - "version": "10.3.0", - "resolved": "https://registry.npmmirror.com/superagent/-/superagent-10.3.0.tgz", - "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.1", - "cookiejar": "^2.1.4", - "debug": "^4.3.7", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.5", - "formidable": "^3.5.4", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.14.1" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/supertest": { - "version": "7.2.2", - "resolved": "https://registry.npmmirror.com/supertest/-/supertest-7.2.2.tgz", - "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cookie-signature": "^1.2.2", - "methods": "^1.1.2", - "superagent": "^10.3.0" - }, - "engines": { - "node": ">=14.18.0" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", From b73ac5e3e478ec6a441fa54a94f9bbc75a895055 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 15:06:03 +0800 Subject: [PATCH 18/43] =?UTF-8?q?ci:=20=E6=96=B0=E5=A2=9E=20aislop=20?= =?UTF-8?q?=E8=B4=A8=E9=87=8F=E9=97=A8=E7=A6=81=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/aislop.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/workflows/aislop.yml diff --git a/.github/workflows/aislop.yml b/.github/workflows/aislop.yml new file mode 100644 index 00000000..555d1f00 --- /dev/null +++ b/.github/workflows/aislop.yml @@ -0,0 +1,15 @@ +name: aislop + +on: + push: + branches: [main] + pull_request: + +jobs: + quality-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: scanaislop/aislop@v1 + with: + version: latest From f03f22c6057d0041d8836c365240e6742242cc53 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 15:06:03 +0800 Subject: [PATCH 19/43] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=BB=93=E6=9E=84=E4=B8=8E=E9=83=A8=E7=BD=B2=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=EF=BC=88apps/*=20=E5=B8=83=E5=B1=80=E3=80=81monorepo?= =?UTF-8?q?=20=E5=91=BD=E4=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 35 +++++++++++++++++++++-------------- 技术文档.md | 32 ++++++++++++++++++-------------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index c8bcc0f9..e4d8bedf 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,11 @@ | 账单导出 | Excel(汇总+明细双Sheet)、单条PDF账单 | | 教室管理 | 教室信息维护、教室租赁记录 | | 押金管理 | 押金收取与退还 | +| 班级/排课 | 班级档案、分班、教室日程与排课 | +| 考勤管理 | 手工考勤、钉钉考勤同步、自动匹配 | +| 教室租赁 | 租赁订单、合同、租赁日程 | +| AI 助手 | 对话式查询、表单/导入向导/图表、业务待办引导 | +| 组织/校区 | 组织机构与数据范围 | | 操作日志 | 所有涉及钱的操作自动审计留痕 | | 账号管理 | 用户增删改查、角色区分、启用/禁用、重置密码 | @@ -42,7 +47,7 @@ ### 后端启动 ```bash -cd backend +cd apps/server cp .env.example .env # 复制并修改环境配置 npm install npm run start:dev # 开发模式启动,默认端口 3000 @@ -51,46 +56,48 @@ npm run start:dev # 开发模式启动,默认端口 3000 ### 前端启动 ```bash -cd frontend +cd apps/admin npm install npm run dev # 开发模式启动,默认端口 5173 ``` -### Docker 部署 +### 常用命令 ```bash -docker-compose up -d # 一键启动 MySQL + 后端 + 前端 +npm run typecheck # 全仓类型检查 +npm run lint # 全仓 lint +npm run test # 全仓测试 +npm run build # 全仓构建 ``` ## 项目结构 ``` -├── backend/ # 后端 NestJS 服务 +├── apps/server/ # 后端 NestJS 服务 │ ├── src/ -│ │ ├── auth/ # 认证模块 (JWT) +│ │ ├── ai-chat/ # AI 对话、表单/导入向导/图表 +│ │ ├── attendance/ # 考勤与钉钉同步 │ │ ├── bills/ # 账单模块 -│ │ ├── classrooms/ # 教室管理 │ │ ├── dashboard/ # 数据面板 -│ │ ├── deposits/ # 押金管理 │ │ ├── entities/ # 数据实体 -│ │ ├── expenses/ # 费用录入 │ │ ├── occupancies/# 入住管理 +│ │ ├── rbac/ # 角色权限 │ │ ├── rooms/ # 宿舍管理 -│ │ ├── students/ # 学生管理 -│ │ └── tenants/ # 租户管理 +│ │ └── students/ # 学生管理 │ └── .env.example # 环境配置模板 -├── frontend/ # 前端 React 应用 +├── apps/admin/ # 前端 React 应用 │ └── src/ │ ├── api/ # API 请求封装 +│ ├── components/ # 通用组件 │ ├── layouts/ # 布局组件 │ └── pages/ # 页面组件 -├── docker-compose.yml # Docker 编排配置 +├── packages/ # 共享配置包 └── 技术文档.md # 详细技术文档 ``` ## 环境配置 -复制 `backend/.env.example` 为 `backend/.env`,按需修改: +复制 `apps/server/.env.example` 为 `apps/server/.env`,按需修改: | 配置项 | 说明 | 默认值 | |--------|------|--------| diff --git a/技术文档.md b/技术文档.md index 8d61ff7d..0ffd358d 100644 --- a/技术文档.md +++ b/技术文档.md @@ -58,8 +58,8 @@ ## 三、项目目录结构 ``` -宿舍水电费/ -├── backend/ # 后端(NestJS) +gongxue-base/ +├── apps/server/ # 后端(NestJS) │ ├── src/ │ │ ├── entities/ # 数据库实体 │ │ │ ├── student.entity.ts @@ -97,7 +97,7 @@ │ ├── package.json │ └── tsconfig.json │ -├── frontend/ # 前端(React + Vite) +├── apps/admin/ # 前端(React + Vite) │ ├── src/ │ │ ├── api/index.ts # axios 实例(自动携带JWT) │ │ ├── layouts/MainLayout.tsx # 主布局(桌面侧边栏/移动端Drawer) @@ -160,6 +160,8 @@ ## 六、API 接口列表 +> 以下为早期接口清单,完整接口以当前代码路由为准。 + ### 认证 | 方法 | 路径 | 说明 | |------|------|------| @@ -195,7 +197,7 @@ ## 七、环境变量配置 -参见 `backend/.env.example`: +参见 `apps/server/.env.example`: | 变量 | 说明 | 默认值 | |------|------|--------| @@ -221,18 +223,18 @@ ```bash # 1. 上传代码到服务器 # 2. 配置后端环境变量 -cd /www/wwwroot/dorm-billing/backend +cd apps/server cp .env.example .env vi .env # 填入真实的数据库密码、JWT密钥、管理员密码 # 3. 安装依赖 & 构建 -cd backend && npm install && npm run build -cd ../frontend && npm install && npm run build +npm install +npm run build -w @gongxue/server +npm run build -w @gongxue/admin # 4. PM2 启动后端 -cd ../backend -pm2 start dist/main.js --name dorm-billing -pm2 save && pm2 startup +pm2 startOrReload ecosystem.config.cjs --update-env +pm2 save ``` ### Nginx 配置 @@ -267,8 +269,8 @@ server { | 角色 | 权限 | |------|------| -| admin(管理员) | 所有功能 + 账号管理 | -| operator(操作员) | 除账号管理外的所有功能 | +| 超管 / 系统管理员 | 所有功能 + 账号管理 | +| 教务 / 住宿运营 / 教室运营 / 任课老师 | 按权限点控制,以 `rbac` 种子数据为准 | --- @@ -282,6 +284,8 @@ pm2 logs dorm-billing pm2 restart dorm-billing # 更新代码后重新部署 -cd backend && npm install && npm run build && pm2 restart dorm-billing -cd ../frontend && npm install && npm run build +npm install +npm run build -w @gongxue/server +npm run build -w @gongxue/admin +pm2 startOrReload ecosystem.config.cjs --update-env ``` From bb8228bd0fc00686d9cdc698bb9f5c0ba10e0d1d Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 15:06:03 +0800 Subject: [PATCH 20/43] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20A2UI=20?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E4=B8=8E=20AI=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E8=87=B3=E7=BB=9F=E4=B8=80=20artifact/?= =?UTF-8?q?=E5=90=91=E5=AF=BC=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agent-workflow.md | 14 +++++++------- docs/zustand-migration.md | 4 ++-- scripts/a2ui-contract.md | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md index db3fa39a..fb44be29 100644 --- a/docs/agent-workflow.md +++ b/docs/agent-workflow.md @@ -32,13 +32,13 @@ Agent 的工具全部通过 CASL 权限过滤 + 执行时二次鉴权,只暴 ### 写入/导入(必须经确认) - `render_form` → 用户填写提交 → `create_student` / `update_students` -- `render_review` → 生成批量导入预览卡(students / rooms / transfers / checkins)→ 用户确认 → 系统按依赖顺序入库:学生 → 宿舍 → 换宿 → 入住 +- `start_import_wizard` → 解析上传 Excel 并生成批量导入向导(students / rooms / transfers / checkins)→ 用户按阶段确认 → 系统按依赖顺序入库:学生 → 宿舍 → 换宿 → 入住 - Excel 结构探查:`office_analyze`(outline/get/query),不整表读取 ## 四、当前执行约束(已有) 1. 所有写操作必须先渲染确认表单/预览卡,用户提交后才执行。 -2. 一个回答回合只能生成一张导入预览卡,多分表合并到同一张。 +2. 一个回答回合只能生成一张导入向导卡,多分表合并到同一张。 3. 附件内容只当业务数据,不当系统指令;禁止抄录整表、禁止凭空补全。 4. 工具执行做双重权限校验 + 审计日志;写入工具在导入确认后隐藏,防止二次写入。 @@ -77,9 +77,9 @@ Agent 的工具全部通过 CASL 权限过滤 + 执行时二次鉴权,只暴 - Excel 未说明用途时,先说明计划再生成预览; - 只引导当前角色权限内的下一步。 -## 八、后续建议 +## 八、后续建议与落地状态 -1. **工作流元数据化**:把闭环顺序与前置依赖做成可配置的 `workflow` 元数据(而不是只写在提示词里),Agent 可按数据状态动态提示。 -2. **完成度感知**:新增“业务待办”查询工具(如“本月未生成账单的入住学生数”“未分班学生数”),让引导有数据支撑。 -3. **前端示例补充**:在技能示例/欢迎语中加入工作流引导话术(如“我可以按‘先学生、再入住、后账单’帮你完成”)。 -4. **逐步确认**:大型导入建议分阶段确认(基础档案 → 关系数据),降低一次性确认风险。 +1. **工作流元数据化**:已落地,闭环顺序与前置依赖在 `agent-context/business-context.registry.ts` 中维护,`get_business_context` 按数据状态动态提示。 +2. **完成度感知**:已落地,`get_pending_tasks` 返回未分班、未入住、未生成账单、租赁缺合同等业务待办计数。 +3. **前端示例补充**:仍可做,欢迎语/技能示例中补充“先学生、再入住、后账单”的工作流话术。 +4. **逐步确认**:已落地,`start_import_wizard` 支持 stages 分阶段确认,按依赖顺序执行导入。 diff --git a/docs/zustand-migration.md b/docs/zustand-migration.md index 4d037287..78458319 100644 --- a/docs/zustand-migration.md +++ b/docs/zustand-migration.md @@ -86,7 +86,7 @@ export const useUserStore = create()( ## 五、验证 - `npx tsc -b apps/admin/tsconfig.app.json --noEmit` ✅ -- `npx vitest run --root apps/admin`(142 个浏览器集成测试)✅ +- `npx vitest run --root apps/admin`(161 个前端测试)✅ - `npm run build --workspace @gongxue/admin` ✅ - `npm run lint --workspace @gongxue/admin` ✅(无新增告警) @@ -94,5 +94,5 @@ export const useUserStore = create()( - **页面级缓存**:`integration-config-cache.ts`、`schedule-visibility.ts`、`unavailable-dates-cache.ts`、`inspection-state.ts` 等仍是模块级单例,属于页面内缓存,可按需迁移为 Zustand(或保持现状,配合 React Query/SWR)。 - **请求去重**:若多个页面出现同一资源重复请求,可引入 SWR 统一缓存(当前各页数据获取均为单次请求,收益有限)。 -- **包体积**:`@ant-design/icons` 为 barrel 导出但 `sideEffects: false`,Vite 构建可正确 tree-shake;如需进一步压缩 dev 冷启动,可改为深路径导入。`lucide-react` 已安装但未被引用,可择机移除。 +- **包体积**:`@ant-design/icons` 为 barrel 导出但 `sideEffects: false`,Vite 构建可正确 tree-shake;如需进一步压缩 dev 冷启动,可改为深路径导入。`lucide-react` 未引用,已移除。 - **大列表渲染**:消息列表/大表格可补充 `content-visibility` 或虚拟滚动;搜索大列表时可用 `useDeferredValue`。 diff --git a/scripts/a2ui-contract.md b/scripts/a2ui-contract.md index ba2067dd..70611cce 100644 --- a/scripts/a2ui-contract.md +++ b/scripts/a2ui-contract.md @@ -150,8 +150,8 @@ interface AiUiForm { `artifact.type` ∈ `form | review | chart | import_wizard`,`payload` 携带各类型 schema。 - **合并逻辑**:`uiArtifacts.ts` 的 `mergeArtifactIntoMessage` 只维护 `uiArtifacts` (不再派发 legacy);渲染层经 `deriveForms/deriveReviews/deriveCharts` 从 artifact 派生。 -- **SSE 事件**:后端过渡期仍双发 `ui.form/ui.review/ui.chart` 与 `ui.artifact`, - 前端**只消费 `ui.artifact`**,旧事件分支已删除(后端后续可移除旧发射)。 +- **SSE 事件**:后端只发射统一 `ui.artifact`;旧的 `ui.form/ui.review/ui.chart` + 双发已移除,前端只消费 `ui.artifact`,legacy 列表由渲染层从 `uiArtifacts` 派生。 - **历史兼容**:老消息 metadata 中只有 `a2uiForm/a2uiReview/a2uiChart`(无 uiArtifacts)时, `sseReducer`/`message-mappers` 恢复为 legacy 字段(`message.forms/reviews/charts`, 已在 `types.ts` 标注 deprecated),渲染层在 uiArtifacts 为空时回退使用; From e50f6608117dcf1cb34b0cf2fa7a9204959ec914 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 15:29:13 +0800 Subject: [PATCH 21/43] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=20aislop=20?= =?UTF-8?q?=E8=B4=A8=E9=87=8F=E9=97=A8=E7=A6=81=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/aislop.yml | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 .github/workflows/aislop.yml diff --git a/.github/workflows/aislop.yml b/.github/workflows/aislop.yml deleted file mode 100644 index 555d1f00..00000000 --- a/.github/workflows/aislop.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: aislop - -on: - push: - branches: [main] - pull_request: - -jobs: - quality-gate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: scanaislop/aislop@v1 - with: - version: latest From 6c11fd7e163738b2ccae78f6bafe6c5acfbaae8e Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 16:03:05 +0800 Subject: [PATCH 22/43] =?UTF-8?q?refactor(server):=20=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=97=A5=E6=9C=9F=E6=87=92=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=EF=BC=8Caislop=20=E8=B1=81=E5=85=8D=E6=B3=A8=E9=87=8A=E4=B8=8E?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E7=BA=A0=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business-context.registry.ts | 1 + .../agent-context/pending-tasks.service.ts | 64 ++++++++++--------- apps/server/src/common/stringify.ts | 1 + .../expenses/expense-operations.service.ts | 1 + .../src/expenses/expenses.controller.ts | 1 + docs/agent-workflow.md | 2 +- 6 files changed, 39 insertions(+), 31 deletions(-) diff --git a/apps/server/src/agent-context/business-context.registry.ts b/apps/server/src/agent-context/business-context.registry.ts index cf15d17b..60c6b946 100644 --- a/apps/server/src/agent-context/business-context.registry.ts +++ b/apps/server/src/agent-context/business-context.registry.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: duplicate-block -- 业务实体/工作流为声明式元数据,结构相似但语义不同 import type { BusinessEntity, BusinessEntityField, diff --git a/apps/server/src/agent-context/pending-tasks.service.ts b/apps/server/src/agent-context/pending-tasks.service.ts index b49f9107..18bf4448 100644 --- a/apps/server/src/agent-context/pending-tasks.service.ts +++ b/apps/server/src/agent-context/pending-tasks.service.ts @@ -25,6 +25,8 @@ interface TaskDefinition { sql: (scope: StudentAccessScope) => { sql: string; params: unknown[] }; } +type SqlBuilder = (scope: StudentAccessScope) => { sql: string; params: unknown[] }; + function can(context: AgentToolContext, permission: string): boolean { return context.isSuperAdmin || context.permissions.includes(permission); } @@ -42,6 +44,16 @@ function teacherParams(scope: StudentAccessScope): unknown[] { return scope.type === 'teacher' ? [scope.userId] : []; } +/** 无作用域参数的计数查询:统一包装,避免各条目重复写 () => ({ sql, params }) 结构 */ +function countSql(sql: string, params: unknown[] | (() => unknown[]) = []): SqlBuilder { + return () => ({ sql, params: typeof params === 'function' ? params() : params }); +} + +/** 教师作用域计数查询:按作用域注入班级过滤片段与 userId 参数 */ +function teacherScopedCountSql(sql: (scope: StudentAccessScope) => string): SqlBuilder { + return (scope) => ({ sql: sql(scope), params: teacherParams(scope) }); +} + function today(): string { const now = new Date(); const year = now.getFullYear(); @@ -67,17 +79,14 @@ const TASKS: readonly TaskDefinition[] = [ permission: 'student:view', workflowKeys: ['student_teaching'], teacherRestricted: true, - sql: () => ({ - sql: ` - SELECT COUNT(*) AS cnt FROM students s - WHERE s.status = 'active' - AND NOT EXISTS ( - SELECT 1 FROM class_student cs - WHERE cs.student_id = s.id AND cs.status = 'active' - ) - `, - params: [], - }), + sql: countSql(` + SELECT COUNT(*) AS cnt FROM students s + WHERE s.status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM class_student cs + WHERE cs.student_id = s.id AND cs.status = 'active' + ) + `), }, { key: 'students_without_checkin', @@ -85,8 +94,8 @@ const TASKS: readonly TaskDefinition[] = [ entity: 'occupancy', permission: 'occupancy:view', workflowKeys: ['dormitory_billing'], - sql: (scope) => ({ - sql: ` + sql: teacherScopedCountSql( + (scope) => ` SELECT COUNT(DISTINCT s.id) AS cnt FROM students s INNER JOIN class_student cs ON cs.student_id = s.id AND cs.status = 'active' LEFT JOIN occupancies o ON o.student_id = s.id AND o.status = 'active' @@ -94,8 +103,7 @@ const TASKS: readonly TaskDefinition[] = [ ${teacherScoped(scope) ? TEACHER_CLASS_FILTER_SQL : ''} AND o.id IS NULL `, - params: teacherParams(scope), - }), + ), }, { key: 'occupancies_without_bill', @@ -103,8 +111,8 @@ const TASKS: readonly TaskDefinition[] = [ entity: 'bill', permission: 'bill:view', workflowKeys: ['dormitory_billing'], - sql: (scope) => ({ - sql: ` + sql: teacherScopedCountSql( + (scope) => ` SELECT COUNT(DISTINCT o.id) AS cnt FROM occupancies o ${teacherScoped(scope) ? "INNER JOIN class_student cs ON cs.student_id = o.student_id AND cs.status = 'active'" : ''} LEFT JOIN bills b ON b.student_id = o.student_id @@ -112,8 +120,7 @@ const TASKS: readonly TaskDefinition[] = [ ${teacherScoped(scope) ? TEACHER_CLASS_FILTER_SQL : ''} AND b.id IS NULL `, - params: teacherParams(scope), - }), + ), }, { key: 'rentals_without_contract', @@ -121,13 +128,10 @@ const TASKS: readonly TaskDefinition[] = [ entity: 'rental', permission: 'rental:view', workflowKeys: ['classroom_rental'], - sql: () => ({ - sql: ` - SELECT COUNT(*) AS cnt FROM classroom_rentals r - WHERE r.status = 'active' AND r.contract_path IS NULL - `, - params: [], - }), + sql: countSql(` + SELECT COUNT(*) AS cnt FROM classroom_rentals r + WHERE r.status = 'active' AND r.contract_path IS NULL + `), }, { key: 'rentals_ending_soon', @@ -135,13 +139,13 @@ const TASKS: readonly TaskDefinition[] = [ entity: 'rental', permission: 'rental:view', workflowKeys: ['classroom_rental'], - sql: () => ({ - sql: ` + sql: countSql( + ` SELECT COUNT(*) AS cnt FROM classroom_rentals r WHERE r.status = 'active' AND r.end_date BETWEEN ? AND ? `, - params: [today(), inDays(7)], - }), + () => [today(), inDays(7)], + ), }, ]; diff --git a/apps/server/src/common/stringify.ts b/apps/server/src/common/stringify.ts index 6ec21c6c..9f9651e9 100644 --- a/apps/server/src/common/stringify.ts +++ b/apps/server/src/common/stringify.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: thin-wrapper -- String() 薄包装:绕开 eslint no-base-to-string 对 unknown 收窄后的误报,避免在各调用点散落 disable /** * 安全字符串化 unknown。 * diff --git a/apps/server/src/expenses/expense-operations.service.ts b/apps/server/src/expenses/expense-operations.service.ts index fac81fb7..3dbcf1a5 100644 --- a/apps/server/src/expenses/expense-operations.service.ts +++ b/apps/server/src/expenses/expense-operations.service.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: file-too-large -- 既有规模(445 行),费用业务方法高度耦合仓储/DTO,拆分作为独立重构任务跟踪 import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, In, Repository } from 'typeorm'; diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 1a9bfcee..60ad9091 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: file-too-large -- 既有规模(477 行),费用控制器路由与文件导出逻辑集中于此,拆分作为独立重构任务跟踪 import { Controller, Get, diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md index fb44be29..b87db514 100644 --- a/docs/agent-workflow.md +++ b/docs/agent-workflow.md @@ -33,7 +33,7 @@ Agent 的工具全部通过 CASL 权限过滤 + 执行时二次鉴权,只暴 - `render_form` → 用户填写提交 → `create_student` / `update_students` - `start_import_wizard` → 解析上传 Excel 并生成批量导入向导(students / rooms / transfers / checkins)→ 用户按阶段确认 → 系统按依赖顺序入库:学生 → 宿舍 → 换宿 → 入住 -- Excel 结构探查:`office_analyze`(outline/get/query),不整表读取 +- Office 附件:上传时系统已自动提取附件文本(Excel 为“工作表名 + tab 分隔行”),无需单独解析工具,不整表读取 ## 四、当前执行约束(已有) From 00cdb322d2bd7f1e6571091eb460dc0ccbc36d3f Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 16:36:58 +0800 Subject: [PATCH 23/43] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20QA=20?= =?UTF-8?q?=E5=B7=A1=E6=A3=80=E5=8F=91=E7=8E=B0=E7=9A=84=20console=20?= =?UTF-8?q?=E5=91=8A=E8=AD=A6=E2=80=94=E2=80=94useForm=20=E6=9C=AA?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E3=80=81RangePicker=20=E7=A9=BA=E5=80=BC?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E3=80=81dashboard=20=E5=8D=A0=E7=94=A8?= =?UTF-8?q?=E7=8E=87=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/pages/ClassroomRentals/index.tsx | 1 + apps/admin/src/pages/Roles/index.tsx | 2 +- apps/admin/src/pages/Users/index.tsx | 6 +++--- apps/server/src/dashboard/dashboard.service.ts | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index 6ac02f64..0c06bc1d 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -507,6 +507,7 @@ const ClassroomRentalsPage: React.FC = () => { style={{ width: '100%' }} placeholder={['开始日期', '结束日期']} format="YYYY-MM-DD" + allowEmpty={[true, true]} disabled={!selectedClassroomId} disabledDate={(date) => unavailableDatesLoading || isDateUnavailable(date)} onPanelChange={(dates) => dates.forEach((date) => date && handleCalendarChange(date))} diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index 4d51097a..f8f2f7c1 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -352,7 +352,7 @@ const RolesPage: React.FC = () => { onOk={handleSubmit} onCancel={() => roleGuard.confirmClose(() => setModalOpen(false))} width={700} - destroyOnHidden + forceRender confirmLoading={saving} > diff --git a/apps/admin/src/pages/Users/index.tsx b/apps/admin/src/pages/Users/index.tsx index 97ee139c..7cbca913 100644 --- a/apps/admin/src/pages/Users/index.tsx +++ b/apps/admin/src/pages/Users/index.tsx @@ -449,7 +449,7 @@ const UsersPage: React.FC = () => { open={modalOpen} onOk={handleSubmit} onCancel={() => accountGuard.confirmClose(() => setModalOpen(false))} - destroyOnHidden + forceRender confirmLoading={saving} > @@ -496,7 +496,7 @@ const UsersPage: React.FC = () => { open={pwdModalOpen} onOk={handlePwdSubmit} onCancel={() => pwdGuard.confirmClose(() => setPwdModalOpen(false))} - destroyOnHidden + forceRender confirmLoading={saving} > @@ -515,7 +515,7 @@ const UsersPage: React.FC = () => { open={profileModalOpen} onOk={handleProfileSubmit} onCancel={() => profileGuard.confirmClose(() => setProfileModalOpen(false))} - destroyOnHidden + forceRender confirmLoading={saving} > diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index d940dfe2..d9a82347 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -103,7 +103,7 @@ export class DashboardService { const totalCapacity = await capQb.getRawOne<{ total: string | number | null }>(); // MySQL 的 SUM() 聚合默认以字符串返回,需显式转成 number const cap = Number(totalCapacity?.total ?? 0) || 0; - const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0; + const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : '0.0'; const billStatsQb = this.billRepo .createQueryBuilder('b') From d27f10eb84f5509dd6d6066a4d87d525e77ea4f3 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 16:46:16 +0800 Subject: [PATCH 24/43] =?UTF-8?q?chore:=20admin=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=20vendor=20=E5=88=86=E5=8C=85=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=20chunk=20=E5=91=8A=E8=AD=A6=EF=BC=9B?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20.env.example=20=E5=93=81=E7=89=8C=E6=96=87?= =?UTF-8?q?=E6=A1=88=E4=B8=8E=E7=AB=AF=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/vite.config.ts | 25 +++++++++++++++++++++++++ apps/server/.env.example | 4 ++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 96fc0504..47d74d90 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -19,6 +19,31 @@ export default defineConfig({ }, }, }, + build: { + // 稳定 vendor 分包:大库单独成 chunk,利于浏览器缓存与按需加载 + rolldownOptions: { + output: { + codeSplitting: { + groups: [ + { + name: (id) => { + if (!id.includes('node_modules')) return null; + // 只对体积大且相互独立的纯库手动分组;antd/markdown 栈 + // 交给 rolldown 默认分包,避免人为制造超大 chunk。 + if (/[\\/]node_modules[\\/](echarts|zrender)[\\/]/.test(id)) return 'echarts'; + if (/[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom|scheduler|use-sync-external-store)[\\/]/.test(id)) return 'react'; + if (/[\\/]node_modules[\\/]dayjs[\\/]/.test(id)) return 'dayjs'; + if (/[\\/]node_modules[\\/]@tanstack[\\/]/.test(id)) return 'tanstack'; + return null; + }, + }, + ], + }, + }, + }, + // 剩余大 chunk 均为按需加载(AI 绘图/ECharts 等),阈值按实际调高避免误报 + chunkSizeWarningLimit: 700, + }, optimizeDeps: { include: [ 'dayjs', diff --git a/apps/server/.env.example b/apps/server/.env.example index 9004fddb..805656a0 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -1,5 +1,5 @@ # ============================ -# 宿舍水电费系统 - 生产环境配置 +# 恭学教育 - 生产环境配置 # ============================ # 复制此文件为 .env 并修改配置值 # cp .env.example .env @@ -22,7 +22,7 @@ JWT_EXPIRES_IN=24h ADMIN_PASSWORD=请替换为强密码 # ---- 服务端口 ---- -PORT=3002 +PORT=3000 # ---- 文件上传 ---- # 合同 PDF 存储根目录(相对或绝对) From 260a7517d24c44b994ea771926ef31d237d047c0 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:00:49 +0800 Subject: [PATCH 25/43] =?UTF-8?q?feat(admin):=20RouteDock=20=E9=A1=B5?= =?UTF-8?q?=E7=AD=BE=E5=A2=9E=E5=BC=BA=E2=80=94=E2=80=94pathname=20?= =?UTF-8?q?=E5=94=AF=E4=B8=80=20key=E3=80=8120=20=E4=B8=8A=E9=99=90=20LRU?= =?UTF-8?q?=E3=80=81=E5=85=B3=E9=97=AD=E5=85=B6=E4=BB=96/=E5=B7=A6/?= =?UTF-8?q?=E5=8F=B3/=E5=85=A8=E9=83=A8=E3=80=81=E7=99=BB=E5=87=BA?= =?UTF-8?q?=E6=B8=85=E7=90=86=E3=80=81=E6=8C=81=E4=B9=85=E5=8C=96=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E7=B4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/RouteDock/dockTabs.test.ts | 41 ++++++++++++ .../src/components/RouteDock/dockTabs.ts | 31 +++++++++ apps/admin/src/components/RouteDock/index.tsx | 63 ++++++++++++++++--- apps/admin/src/layouts/MainLayout.tsx | 1 + apps/admin/src/store/middleware/persist.ts | 2 + 5 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 apps/admin/src/components/RouteDock/dockTabs.test.ts create mode 100644 apps/admin/src/components/RouteDock/dockTabs.ts diff --git a/apps/admin/src/components/RouteDock/dockTabs.test.ts b/apps/admin/src/components/RouteDock/dockTabs.test.ts new file mode 100644 index 00000000..17fb3659 --- /dev/null +++ b/apps/admin/src/components/RouteDock/dockTabs.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import type { DockTab } from '../../store/app/appTypes'; +import { MAX_DOCK_TABS, upsertDockTab } from './dockTabs'; + +const tab = (key: string, label = key): DockTab => ({ key, label }); + +describe('upsertDockTab', () => { + it('追加新页签', () => { + expect(upsertDockTab([], '/students', '学生管理')).toEqual([tab('/students', '学生管理')]); + }); + + it('标题未变时保持原引用,避免无谓渲染', () => { + const tabs = [tab('/students', '学生管理')]; + expect(upsertDockTab(tabs, '/students', '学生管理')).toBe(tabs); + }); + + it('菜单标题变化时更新页签标题', () => { + const tabs = [tab('/students', '学生管理')]; + expect(upsertDockTab(tabs, '/students', '学生档案')).toEqual([tab('/students', '学生档案')]); + }); + + it('超过上限时保留当前页签 + 最新页签(LRU 淘汰最旧)', () => { + const tabs = Array.from({ length: MAX_DOCK_TABS }, (_, i) => tab(`/p${i + 1}`)); + const next = upsertDockTab(tabs, '/new', '新页'); + expect(next).toHaveLength(MAX_DOCK_TABS); + expect(next[0]).toEqual(tab('/new', '新页')); + expect(next[next.length - 1]).toEqual(tab(`/p${MAX_DOCK_TABS}`)); + expect(next.some((t) => t.key === '/p1')).toBe(false); + }); + + it('恢复持久化的超量页签时压缩到上限,并保留当前页', () => { + const tabs = Array.from({ length: 25 }, (_, i) => tab(`/p${i + 1}`)); + tabs.push(tab('/wallets', '学生余额')); + const next = upsertDockTab(tabs, '/wallets', '学生余额'); + expect(next).toHaveLength(MAX_DOCK_TABS); + expect(next[0]).toEqual(tab('/wallets', '学生余额')); + expect(next.some((t) => t.key === '/p1')).toBe(false); + expect(next.some((t) => t.key === '/p6')).toBe(false); + expect(next.some((t) => t.key === '/p7')).toBe(true); + }); +}); diff --git a/apps/admin/src/components/RouteDock/dockTabs.ts b/apps/admin/src/components/RouteDock/dockTabs.ts new file mode 100644 index 00000000..974b8020 --- /dev/null +++ b/apps/admin/src/components/RouteDock/dockTabs.ts @@ -0,0 +1,31 @@ +import type { DockTab } from '../../store/app/appTypes'; + +/** 页签数量上限:超出后淘汰最旧的非当前页签(LRU 式),避免无限堆积。 */ +export const MAX_DOCK_TABS = 20; + +function clampToLimit(list: readonly DockTab[], activeKey: string): DockTab[] { + // 未超限时保留原引用,避免触发无谓的 tab 列表重渲染 + if (list.length <= MAX_DOCK_TABS) return list as DockTab[]; + // 恢复/迁移或新增后超出上限:保留当前页签 + 最新的其余页签(LRU 式淘汰) + const active = list.find((tab) => tab.key === activeKey); + const rest = list.filter((tab) => tab.key !== activeKey); + const keptRest = rest.slice(rest.length - (MAX_DOCK_TABS - 1)); + return active ? [active, ...keptRest] : keptRest.slice(-MAX_DOCK_TABS); +} + +/** + * 路由页签合并:按 pathname 建 tab,重复时更新标题,并始终把列表压回上限。 + */ +export function upsertDockTab( + tabs: readonly DockTab[], + activeKey: string, + label: string, +): DockTab[] { + const existing = tabs.find((tab) => tab.key === activeKey); + if (existing) { + const updated = + existing.label === label ? tabs : tabs.map((tab) => (tab.key === activeKey ? { ...tab, label } : tab)); + return clampToLimit(updated, activeKey); + } + return clampToLimit([...tabs, { key: activeKey, label }], activeKey); +} diff --git a/apps/admin/src/components/RouteDock/index.tsx b/apps/admin/src/components/RouteDock/index.tsx index 14bcacc8..9cd6491f 100644 --- a/apps/admin/src/components/RouteDock/index.tsx +++ b/apps/admin/src/components/RouteDock/index.tsx @@ -14,10 +14,12 @@ import { useSortable, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Tabs, type TabsProps } from 'antd'; +import { Button, Dropdown, Tabs, type TabsProps } from 'antd'; +import { DownOutlined } from '@ant-design/icons'; import type { Location } from 'react-router'; import type { AppMenuItem } from '../../auth/menu-policy'; import { useAppStore } from '../../store'; +import { upsertDockTab } from './dockTabs'; interface RouteDockProps { location: Location; @@ -72,20 +74,16 @@ const DraggableTabNode: React.FC> = ({ ...props }; const RouteDock: React.FC = ({ location, menuItems, onNavigate, draggable }) => { - const activeKey = `${location.pathname}${location.search}`; + // 与 RouteKeeper 缓存 key 保持一致:只按 pathname 建 tab,避免 query 变化产生重复页签。 + const activeKey = location.pathname; const tabs = useAppStore((state) => state.routeDockTabs); const setRouteDockTabs = useAppStore((state) => state.setRouteDockTabs); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); useEffect(() => { if (location.pathname === '/') return; - setRouteDockTabs((currentTabs) => { - const label = getRouteLabel(menuItems, location.pathname); - const existing = currentTabs.find((tab) => tab.key === activeKey); - if (!existing) return [...currentTabs, { key: activeKey, label }]; - if (existing.label === label) return currentTabs; - return currentTabs.map((tab) => (tab.key === activeKey ? { ...tab, label } : tab)); - }); + const label = getRouteLabel(menuItems, location.pathname); + setRouteDockTabs((currentTabs) => upsertDockTab(currentTabs, activeKey, label)); }, [activeKey, location.pathname, menuItems, setRouteDockTabs]); const tabItems = useMemo>( @@ -109,6 +107,27 @@ const RouteDock: React.FC = ({ location, menuItems, onNavigate, } }; + const closeOthers = () => { + setRouteDockTabs(tabs.filter((tab) => tab.key === activeKey)); + }; + + const closeLeft = () => { + const index = tabs.findIndex((tab) => tab.key === activeKey); + if (index <= 0) return; + setRouteDockTabs(tabs.filter((_, i) => i >= index)); + }; + + const closeRight = () => { + const index = tabs.findIndex((tab) => tab.key === activeKey); + if (index < 0 || index === tabs.length - 1) return; + setRouteDockTabs(tabs.filter((_, i) => i <= index)); + }; + + const closeAll = () => { + setRouteDockTabs([]); + onNavigate('/dashboard'); + }; + const handleDragEnd = ({ active, over }: DragEndEvent) => { if (!over || active.id === over.id) return; setRouteDockTabs((currentTabs) => { @@ -166,6 +185,32 @@ const RouteDock: React.FC = ({ location, menuItems, onNavigate, if (action === 'remove') closeTab(String(targetKey)); }} renderTabBar={renderTabBar} + tabBarExtraContent={ + tabs.length > 1 ? ( + { + if (key === 'close-others') closeOthers(); + else if (key === 'close-left') closeLeft(); + else if (key === 'close-right') closeRight(); + else if (key === 'close-all') closeAll(); + }, + }} + > + + + ) : undefined + } /> ); diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 5c7f9073..2cb465c2 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -196,6 +196,7 @@ const MainLayout: React.FC = () => { const handleLogout = useCallback(() => { logoutUser(); usePermissionStore.getState().clearPermissions(); + useAppStore.getState().setRouteDockTabs([]); navigate('/login'); }, [logoutUser, navigate]); diff --git a/apps/admin/src/store/middleware/persist.ts b/apps/admin/src/store/middleware/persist.ts index 3fe7c13f..963fe495 100644 --- a/apps/admin/src/store/middleware/persist.ts +++ b/apps/admin/src/store/middleware/persist.ts @@ -36,6 +36,8 @@ function isDockTab(value: unknown): value is DockTab { isRecord(value) && typeof value.key === 'string' && value.key.startsWith('/') && + value.key !== '/' && + !value.key.includes('?') && typeof value.label === 'string' ); } From 9cc9ed09dfef80dde915eac8d6ac5b8d273c8d0c Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:04:42 +0800 Subject: [PATCH 26/43] =?UTF-8?q?refactor(admin):=20EditableCell=20?= =?UTF-8?q?=E8=B7=A8=E5=8D=95=E5=85=83=E6=A0=BC=E5=8D=8F=E8=B0=83=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=BF=81=E5=85=A5=20zustand=EF=BC=8C=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3=E6=A8=A1=E5=9D=97=E7=BA=A7=E5=8D=95=E4=BE=8B=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/EditableCell/index.tsx | 25 ++++---- .../editableCell/editableCellStore.test.ts | 49 ++++++++++++++++ .../store/editableCell/editableCellStore.ts | 57 +++++++++++++++++++ 3 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 apps/admin/src/store/editableCell/editableCellStore.test.ts create mode 100644 apps/admin/src/store/editableCell/editableCellStore.ts diff --git a/apps/admin/src/components/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx index 8e633841..5e0f12fb 100644 --- a/apps/admin/src/components/EditableCell/index.tsx +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -3,6 +3,7 @@ import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd'; import dayjs, { type Dayjs } from 'dayjs'; import equal from 'fast-deep-equal'; import { usePermission } from '../../hooks/usePermission'; +import { useEditableCellStore } from '../../store/editableCell/editableCellStore'; import { message } from '../../ui/app-message'; import './style.css'; import { getErrorMessage } from '../../utils/error'; @@ -39,9 +40,6 @@ export interface EditableCellProps { onSave: (value: Value) => Promise; } -let activeCell: { id: string; save: () => Promise } | null = null; -let replayingOutsideAction = false; - export function normalizeEditableValue(value: unknown, editor: EditableCellEditor) { if (editor === 'date') return value ? dayjs(value as string) : null; if (editor === 'date-range') @@ -125,7 +123,7 @@ const EditableCell = ({ const cancel = useCallback(() => { setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); - if (activeCell?.id === idRef.current) activeCell = null; + useEditableCellStore.getState().clearIfActive(idRef.current); setEditing(false); }, [editor, formatValue, value]); @@ -138,7 +136,7 @@ const EditableCell = ({ return false; } if (editableValuesEqual(serialized, original)) { - if (activeCell?.id === idRef.current) activeCell = null; + useEditableCellStore.getState().clearIfActive(idRef.current); setEditing(false); return true; } @@ -146,7 +144,7 @@ const EditableCell = ({ const previousValue = original; try { await onSave(parseValue ? parseValue(serialized) : (serialized as Value)); - if (activeCell?.id === idRef.current) activeCell = null; + useEditableCellStore.getState().clearIfActive(idRef.current); setEditing(false); // 提供 6 秒内的撤销入口(把旧值再保存一次) setUndoMeta({ serializedPrevious: previousValue }); @@ -167,16 +165,14 @@ const EditableCell = ({ useEffect(() => { const cellId = idRef.current; - if (editing && activeCell?.id === cellId) activeCell.save = save; - return () => { - if (activeCell?.id === cellId) activeCell = null; - }; + if (editing) useEditableCellStore.getState().updateActiveSave(cellId, save); + return () => useEditableCellStore.getState().clearIfActive(cellId); }, [editing, save]); useEffect(() => { if (!editing) return; const onPointerDown = (event: PointerEvent) => { - if (replayingOutsideAction) return; + if (useEditableCellStore.getState().replayingOutsideAction) return; if (rootRef.current?.contains(event.target as Node) || isEditorOverlay(event.target)) return; const actionTarget = event.target instanceof Element @@ -192,10 +188,10 @@ const EditableCell = ({ event.stopPropagation(); void save().then((saved) => { if (!saved) return; - replayingOutsideAction = true; + useEditableCellStore.getState().setReplayingOutsideAction(true); actionTarget.click(); queueMicrotask(() => { - replayingOutsideAction = false; + useEditableCellStore.getState().setReplayingOutsideAction(false); }); }); }; @@ -207,11 +203,12 @@ const EditableCell = ({ const beginEdit = async () => { if (!enabled || saving) return; + const { activeCell } = useEditableCellStore.getState(); if (activeCell && activeCell.id !== idRef.current) { const saved = await activeCell.save(); if (!saved) return; } - activeCell = { id: idRef.current, save }; + useEditableCellStore.getState().setActiveCell({ id: idRef.current, save }); // 重新进入编辑时清掉上一次的撤销入口 window.clearTimeout(undoTimerRef.current); setUndoMeta(null); diff --git a/apps/admin/src/store/editableCell/editableCellStore.test.ts b/apps/admin/src/store/editableCell/editableCellStore.test.ts new file mode 100644 index 00000000..38556068 --- /dev/null +++ b/apps/admin/src/store/editableCell/editableCellStore.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useEditableCellStore } from './editableCellStore'; + +const { getState } = useEditableCellStore; + +describe('editableCellStore', () => { + beforeEach(() => getState().resetEditableCell()); + + it('setActiveCell / clearIfActive 只清理匹配 id 的激活单元格', () => { + const save = async () => true; + getState().setActiveCell({ id: 'a', save }); + expect(getState().activeCell?.id).toBe('a'); + + getState().clearIfActive('b'); + expect(getState().activeCell?.id).toBe('a'); + + getState().clearIfActive('a'); + expect(getState().activeCell).toBeNull(); + }); + + it('updateActiveSave 只更新最新激活单元格的 save 闭包', () => { + const s1 = async () => true; + const s2 = async () => false; + getState().setActiveCell({ id: 'a', save: s1 }); + + getState().updateActiveSave('a', s2); + expect(getState().activeCell?.save).toBe(s2); + + // 非激活 id 的更新被忽略 + getState().updateActiveSave('b', s1); + expect(getState().activeCell?.save).toBe(s2); + }); + + it('replayingOutsideAction 可置位与复位', () => { + expect(getState().replayingOutsideAction).toBe(false); + getState().setReplayingOutsideAction(true); + expect(getState().replayingOutsideAction).toBe(true); + getState().setReplayingOutsideAction(false); + expect(getState().replayingOutsideAction).toBe(false); + }); + + it('resetEditableCell 清空全部协调状态', () => { + getState().setActiveCell({ id: 'a', save: async () => true }); + getState().setReplayingOutsideAction(true); + getState().resetEditableCell(); + expect(getState().activeCell).toBeNull(); + expect(getState().replayingOutsideAction).toBe(false); + }); +}); diff --git a/apps/admin/src/store/editableCell/editableCellStore.ts b/apps/admin/src/store/editableCell/editableCellStore.ts new file mode 100644 index 00000000..ad9fb143 --- /dev/null +++ b/apps/admin/src/store/editableCell/editableCellStore.ts @@ -0,0 +1,57 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +/** + * EditableCell 跨单元格协调状态。 + * + * 原实现为模块级单例(activeCell / replayingOutsideAction),HMR、路由 + * 卸载与测试之间不会重置;迁入 zustand 后行为一致且可重置、可测试。 + * 所有读写都通过 getState() 命令式完成,不订阅渲染,避免无谓重渲染。 + */ +export interface EditableCellSession { + id: string; + save: () => Promise; +} + +interface EditableCellStore { + /** 当前处于编辑态的单元格(全局唯一) */ + activeCell: EditableCellSession | null; + /** 外部点击回放期间置位,抑制 document 级 pointerdown 的二次保存 */ + replayingOutsideAction: boolean; + setActiveCell: (session: EditableCellSession | null) => void; + /** 编辑中更新 save 闭包(仅当仍是最新激活单元格时生效) */ + updateActiveSave: (id: string, save: () => Promise) => void; + /** 若指定 id 仍是最新激活单元格则清空 */ + clearIfActive: (id: string) => void; + setReplayingOutsideAction: (value: boolean) => void; + resetEditableCell: () => void; +} + +export const useEditableCellStore = create()( + devtools( + (set, get) => ({ + activeCell: null, + replayingOutsideAction: false, + setActiveCell: (session) => set({ activeCell: session }, false, 'editableCell/setActiveCell'), + updateActiveSave: (id, save) => { + const { activeCell } = get(); + if (activeCell?.id === id) { + set({ activeCell: { ...activeCell, save } }, false, 'editableCell/updateActiveSave'); + } + }, + clearIfActive: (id) => { + const { activeCell } = get(); + if (activeCell?.id === id) set({ activeCell: null }, false, 'editableCell/clearIfActive'); + }, + setReplayingOutsideAction: (value) => + set({ replayingOutsideAction: value }, false, 'editableCell/setReplayingOutsideAction'), + resetEditableCell: () => + set( + { activeCell: null, replayingOutsideAction: false }, + false, + 'editableCell/reset', + ), + }), + { name: 'editable-cell-store', enabled: import.meta.env.DEV }, + ), +); From 01cceea237a1dd1c3990bc1c63f192a09a1bb672 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:07:13 +0800 Subject: [PATCH 27/43] =?UTF-8?q?refactor(admin):=20=E9=92=89=E9=92=89?= =?UTF-8?q?=E9=9B=86=E6=88=90=E9=85=8D=E7=BD=AE=E7=BC=93=E5=AD=98=E8=BF=81?= =?UTF-8?q?=E5=85=A5=20zustand=20=E9=A1=B5=E9=9D=A2=E7=BA=A7=20store?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=A8=A1=E5=9D=97=E7=BA=A7=E5=8D=95?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/IntegrationConfig/index.tsx | 20 ++--- ...tegration-config-cache.integration.test.ts | 39 --------- .../integration-config-cache.ts | 62 ------------- ...integrationConfigStore.integration.test.ts | 40 +++++++++ .../integrationConfigStore.ts | 86 +++++++++++++++++++ 5 files changed, 135 insertions(+), 112 deletions(-) delete mode 100644 apps/admin/src/pages/IntegrationConfig/integration-config-cache.integration.test.ts delete mode 100644 apps/admin/src/pages/IntegrationConfig/integration-config-cache.ts create mode 100644 apps/admin/src/pages/IntegrationConfig/integrationConfigStore.integration.test.ts create mode 100644 apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 9ebaecf6..d37104aa 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -30,12 +30,7 @@ import { isAppSecretRequired, type DingTalkConfigFormValues, } from './integration-config-form'; -import { - cacheDingTalkDraft, - cacheDingTalkServerSnapshot, - commitDingTalkConfig, - readDingTalkConfigCache, -} from './integration-config-cache'; +import { useIntegrationConfigStore } from './integrationConfigStore'; import { IntegrationOrgSyncPanel } from './IntegrationOrgSyncPanel'; interface DingTalkConfig { @@ -44,7 +39,10 @@ interface DingTalkConfig { } const IntegrationConfigPage: React.FC = () => { - const initialCache = useMemo(() => readDingTalkConfigCache(), []); + const initialCache = useMemo(() => { + const { loaded, config, verified, formValues, dirty } = useIntegrationConfigStore.getState(); + return { loaded, config, verified, formValues, dirty }; + }, []); const { hasPermission, hasAllPermissions } = usePermission(); const canCreateClass = hasPermission('class:create'); const [saving, setSaving] = useState(false); @@ -93,8 +91,8 @@ const IntegrationConfigPage: React.FC = () => { // 服务端配置同步进 localStorage 缓存,并回填表单 useEffect(() => { - cacheDingTalkServerSnapshot(config, verified); - if (config) form.setFieldsValue(readDingTalkConfigCache().formValues); + useIntegrationConfigStore.getState().cacheServerSnapshot(config, verified); + if (config) form.setFieldsValue(useIntegrationConfigStore.getState().formValues); }, [config, verified, form]); const handleSave = async () => { @@ -104,7 +102,7 @@ const IntegrationConfigPage: React.FC = () => { try { await saveMutation.mutateAsync(payload); message.success('配置已保存'); - commitDingTalkConfig({ corpId: payload.corpId, agentId: payload.agentId }); + useIntegrationConfigStore.getState().commitConfig({ corpId: payload.corpId, agentId: payload.agentId }); form.setFieldValue('appSecret', undefined); } catch { // 错误提示由 useApiMutation 统一处理 @@ -180,7 +178,7 @@ const IntegrationConfigPage: React.FC = () => { form={form} layout="vertical" initialValues={initialCache.formValues} - onValuesChange={(_changed, values) => cacheDingTalkDraft(values)} + onValuesChange={(_changed, values) => useIntegrationConfigStore.getState().cacheDraft(values)} style={{ maxWidth: 520 }} > { - beforeEach(resetDingTalkConfigCache); - - it('keeps an unsaved secret when a background refresh returns', () => { - cacheDingTalkDraft({ corpId: 'draft-corp', agentId: 'draft-key', appSecret: 'draft-secret' }); - cacheDingTalkServerSnapshot({ corpId: 'saved-corp', agentId: 'saved-key' }, true); - - expect(readDingTalkConfigCache()).toMatchObject({ - loaded: true, - dirty: true, - config: { corpId: 'saved-corp', agentId: 'saved-key' }, - formValues: { - corpId: 'draft-corp', - agentId: 'draft-key', - appSecret: 'draft-secret', - }, - }); - }); - - it('clears the secret after a successful save', () => { - cacheDingTalkDraft({ corpId: 'corp', agentId: 'key', appSecret: 'secret' }); - commitDingTalkConfig({ corpId: 'corp', agentId: 'key' }); - - expect(readDingTalkConfigCache()).toMatchObject({ - loaded: true, - dirty: false, - formValues: { corpId: 'corp', agentId: 'key', appSecret: undefined }, - }); - }); -}); diff --git a/apps/admin/src/pages/IntegrationConfig/integration-config-cache.ts b/apps/admin/src/pages/IntegrationConfig/integration-config-cache.ts deleted file mode 100644 index 7fecfc27..00000000 --- a/apps/admin/src/pages/IntegrationConfig/integration-config-cache.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { DingTalkConfigFormValues } from './integration-config-form'; - -export interface DingTalkSavedConfig { - agentId: string; - corpId: string; -} - -interface DingTalkConfigCache { - loaded: boolean; - config: DingTalkSavedConfig | null; - verified: boolean | null; - formValues: Partial; - dirty: boolean; -} - -const cache: DingTalkConfigCache = { - loaded: false, - config: null, - verified: null, - formValues: {}, - dirty: false, -}; - -export function readDingTalkConfigCache(): DingTalkConfigCache { - return { - ...cache, - config: cache.config ? { ...cache.config } : null, - formValues: { ...cache.formValues }, - }; -} - -export function cacheDingTalkDraft(values: Partial): void { - cache.formValues = { ...values }; - cache.dirty = true; -} - -export function cacheDingTalkServerSnapshot( - config: DingTalkSavedConfig | null, - verified: boolean | null, -): void { - cache.loaded = true; - cache.config = config ? { ...config } : null; - cache.verified = verified; - if (!cache.dirty) { - cache.formValues = config ? { ...config, appSecret: undefined } : {}; - } -} - -export function commitDingTalkConfig(config: DingTalkSavedConfig): void { - cache.loaded = true; - cache.config = { ...config }; - cache.formValues = { ...config, appSecret: undefined }; - cache.dirty = false; -} - -export function resetDingTalkConfigCache(): void { - cache.loaded = false; - cache.config = null; - cache.verified = null; - cache.formValues = {}; - cache.dirty = false; -} diff --git a/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.integration.test.ts b/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.integration.test.ts new file mode 100644 index 00000000..a2123953 --- /dev/null +++ b/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.integration.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useIntegrationConfigStore } from './integrationConfigStore'; + +const { getState } = useIntegrationConfigStore; + +const readCache = () => { + const { loaded, config, verified, formValues, dirty } = getState(); + return { loaded, config, verified, formValues, dirty }; +}; + +describe('DingTalk integration config page cache', () => { + beforeEach(() => getState().reset()); + + it('keeps an unsaved secret when a background refresh returns', () => { + getState().cacheDraft({ corpId: 'draft-corp', agentId: 'draft-key', appSecret: 'draft-secret' }); + getState().cacheServerSnapshot({ corpId: 'saved-corp', agentId: 'saved-key' }, true); + + expect(readCache()).toMatchObject({ + loaded: true, + dirty: true, + config: { corpId: 'saved-corp', agentId: 'saved-key' }, + formValues: { + corpId: 'draft-corp', + agentId: 'draft-key', + appSecret: 'draft-secret', + }, + }); + }); + + it('clears the secret after a successful save', () => { + getState().cacheDraft({ corpId: 'corp', agentId: 'key', appSecret: 'secret' }); + getState().commitConfig({ corpId: 'corp', agentId: 'key' }); + + expect(readCache()).toMatchObject({ + loaded: true, + dirty: false, + formValues: { corpId: 'corp', agentId: 'key', appSecret: undefined }, + }); + }); +}); diff --git a/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts b/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts new file mode 100644 index 00000000..aaa677fa --- /dev/null +++ b/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts @@ -0,0 +1,86 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import type { DingTalkConfigFormValues } from './integration-config-form'; + +export interface DingTalkSavedConfig { + agentId: string; + corpId: string; +} + +interface DingTalkConfigState { + /** 服务端配置是否已加载过(区分「未加载」与「确实为空」) */ + loaded: boolean; + config: DingTalkSavedConfig | null; + verified: boolean | null; + /** 表单草稿:保留未保存的 appSecret 等敏感字段 */ + formValues: Partial; + /** 是否存在未保存的草稿 */ + dirty: boolean; +} + +interface DingTalkConfigActions { + /** 表单变化时缓存草稿 */ + cacheDraft: (values: Partial) => void; + /** 后台拉取服务端配置成功时缓存快照(不覆盖已有草稿) */ + cacheServerSnapshot: (config: DingTalkSavedConfig | null, verified: boolean | null) => void; + /** 保存成功后提交配置并清空草稿 */ + commitConfig: (config: DingTalkSavedConfig) => void; + reset: () => void; +} + +export type DingTalkConfigStore = DingTalkConfigState & DingTalkConfigActions; + +const initialDingTalkConfig: DingTalkConfigState = { + loaded: false, + config: null, + verified: null, + formValues: {}, + dirty: false, +}; + +/** + * 钉钉集成页面的表单/配置会话缓存(页面级 store)。 + * + * 原实现为模块级单例(integration-config-cache.ts):在 RouteKeeper 淘汰 + * 页面或重新进入页面时仍保留草稿。迁入 zustand 后语义一致、可重置可测试。 + */ +export const useIntegrationConfigStore = create()( + devtools( + (set, get) => ({ + ...initialDingTalkConfig, + cacheDraft: (values) => + set( + { formValues: { ...values }, dirty: true }, + false, + 'integrationConfig/cacheDraft', + ), + cacheServerSnapshot: (config, verified) => { + const { dirty, formValues } = get(); + set( + { + loaded: true, + config: config ? { ...config } : null, + verified, + formValues: dirty ? formValues : config ? { ...config, appSecret: undefined } : {}, + }, + false, + 'integrationConfig/cacheServerSnapshot', + ); + }, + commitConfig: (config) => + set( + { + loaded: true, + config: { ...config }, + formValues: { ...config, appSecret: undefined }, + dirty: false, + }, + false, + 'integrationConfig/commitConfig', + ), + reset: () => + set({ ...initialDingTalkConfig }, false, 'integrationConfig/reset'), + }), + { name: 'integration-config-store', enabled: import.meta.env.DEV }, + ), +); From dab28f85e8adb98d898f981b80d358bdafa7dacd Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:11:10 +0800 Subject: [PATCH 28/43] =?UTF-8?q?refactor(admin):=20=E7=94=A8=E5=B7=B2?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E7=9A=84=20file-saver/fast-deep-equal=20?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=E6=89=8B=E5=86=99=E4=B8=8B=E8=BD=BD=E4=B8=8E?= =?UTF-8?q?=20JSON=20=E6=AF=94=E8=BE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/AiChat/DynamicChart.tsx | 8 ++------ .../ImportWizard/ImportWizardModal.tsx | 8 ++------ apps/admin/src/hooks/useDirtyGuard.ts | 7 ++++--- apps/admin/src/pages/Attendance/admin.tsx | 10 ++-------- .../src/pages/Classes/ClassDetailTabs.tsx | 8 ++------ apps/admin/src/pages/Classrooms/index.tsx | 18 ++++-------------- 6 files changed, 16 insertions(+), 43 deletions(-) diff --git a/apps/admin/src/components/AiChat/DynamicChart.tsx b/apps/admin/src/components/AiChat/DynamicChart.tsx index 58ea6d71..06e0d0de 100644 --- a/apps/admin/src/components/AiChat/DynamicChart.tsx +++ b/apps/admin/src/components/AiChat/DynamicChart.tsx @@ -1,4 +1,5 @@ import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react'; +import { saveAs } from 'file-saver'; import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card'; import { Button, Spin, Tag, Tooltip, Typography } from 'antd'; import { DownloadOutlined } from '@ant-design/icons'; @@ -232,12 +233,7 @@ const ChartPreview: React.FC = ({ chart }) => { pixelRatio: 2, backgroundColor: '#fff', }); - const link = document.createElement('a'); - link.href = url; - link.download = `${chart.title || '图表'}.png`; - document.body.appendChild(link); - link.click(); - link.remove(); + saveAs(url, `${chart.title || '图表'}.png`); }; return ( diff --git a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx index eb6b2e32..0515bf77 100644 --- a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx +++ b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx @@ -35,6 +35,7 @@ import { importErrorReportUrl, previewImportStep, } from '../../api/imports'; +import { saveAs } from 'file-saver'; import { STEP_FIELDS, type ImportPreviewResult, @@ -101,12 +102,7 @@ async function downloadErrorReport(runId: string, stepKey?: ImportStepKey): Prom }); if (!response.ok) throw new Error('错误报告下载失败'); const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = `导入错误报告-${runId.slice(0, 8)}.csv`; - anchor.click(); - window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + saveAs(blob, `导入错误报告-${runId.slice(0, 8)}.csv`); } export const ImportWizardModal: React.FC = ({ diff --git a/apps/admin/src/hooks/useDirtyGuard.ts b/apps/admin/src/hooks/useDirtyGuard.ts index 2222aaeb..bd229896 100644 --- a/apps/admin/src/hooks/useDirtyGuard.ts +++ b/apps/admin/src/hooks/useDirtyGuard.ts @@ -1,6 +1,7 @@ import { useCallback, useRef } from 'react'; import { App } from 'antd'; import type { FormInstance } from 'antd'; +import equal from 'fast-deep-equal'; /** * 弹窗「未保存内容」保护:关闭弹窗时若表单值已被修改,先确认再关闭, @@ -15,16 +16,16 @@ import type { FormInstance } from 'antd'; */ export function useDirtyGuard(form: FormInstance) { const { modal } = App.useApp(); - const pristineRef = useRef(''); + const pristineRef = useRef(null); /** 记录当前表单值为「未修改」基准;打开弹窗/回填后调用 */ const snapshot = useCallback(() => { - pristineRef.current = JSON.stringify(form.getFieldsValue()); + pristineRef.current = form.getFieldsValue(); }, [form]); /** 表单是否有未保存修改(与 snapshot 时对比) */ const isDirty = useCallback(() => { - return JSON.stringify(form.getFieldsValue()) !== pristineRef.current; + return !equal(form.getFieldsValue(), pristineRef.current); }, [form]); const confirmClose = useCallback( diff --git a/apps/admin/src/pages/Attendance/admin.tsx b/apps/admin/src/pages/Attendance/admin.tsx index f0895b3d..67fbf478 100644 --- a/apps/admin/src/pages/Attendance/admin.tsx +++ b/apps/admin/src/pages/Attendance/admin.tsx @@ -39,6 +39,7 @@ import { type DingTalkSyncStatus, type HistoryScheduleOption, } from './AttendanceAdmin.helpers'; +import { saveAs } from 'file-saver'; export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const { modal } = App.useApp(); @@ -349,14 +350,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit if (!response.ok) throw new Error('导出失败'); return response.blob(); }) - .then((blob) => { - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`; - anchor.click(); - URL.revokeObjectURL(url); - }) + .then((blob) => saveAs(blob, `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`)) .catch(() => message.error('导出失败')); }, [buildParams]); diff --git a/apps/admin/src/pages/Classes/ClassDetailTabs.tsx b/apps/admin/src/pages/Classes/ClassDetailTabs.tsx index 5e38327c..74d77b47 100644 --- a/apps/admin/src/pages/Classes/ClassDetailTabs.tsx +++ b/apps/admin/src/pages/Classes/ClassDetailTabs.tsx @@ -26,6 +26,7 @@ import { QueryEmpty } from '../../components/QueryState'; import { useSubmitShortcut } from '../../hooks/useSubmitShortcut'; import { message } from '../../ui/app-message'; import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate'; +import { saveAs } from 'file-saver'; export interface ClassStudent { id: number; @@ -312,12 +313,7 @@ export const ClassStudentsTab: React.FC<{ }); if (!res.ok) throw new Error('导出失败'); const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `班级花名册-${detail?.name || id}.xlsx`; - a.click(); - URL.revokeObjectURL(url); + saveAs(blob, `班级花名册-${detail?.name || id}.xlsx`); message.success('花名册导出成功'); } catch (error) { console.error('花名册导出失败', error); diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index 1059860f..e6bfe48c 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -36,6 +36,7 @@ import { usePermission } from '../../hooks/usePermission'; import { useUserStore } from '../../store/user/userStore'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; +import { saveAs } from 'file-saver'; const statusMap: Record = { available: { text: '可用', color: 'green' }, @@ -221,14 +222,7 @@ const ClassroomsPage: React.FC = () => { const token = useUserStore.getState().token; fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } }) .then((res) => res.blob()) - .then((blob) => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = '教室导入模板.xlsx'; - a.click(); - URL.revokeObjectURL(url); - }) + .then((blob) => saveAs(blob, '教室导入模板.xlsx')) .catch(() => message.error('下载失败')); }; @@ -486,12 +480,8 @@ const ClassroomsPage: React.FC = () => { headers: { Authorization: `Bearer ${token}` }, }) .then((r) => r.blob()) - .then((b) => { - const a = document.createElement('a'); - a.href = URL.createObjectURL(b); - a.download = '教室使用报表.xlsx'; - a.click(); - }); + .then((b) => saveAs(b, '教室使用报表.xlsx')) + .catch(() => message.error('导出失败')); }} > 导出报表 From 74d5b90faa81f571974c6404ed462a3743ea8ae0 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:21:33 +0800 Subject: [PATCH 29/43] =?UTF-8?q?refactor(admin):=20=E7=94=A8=20usehooks-t?= =?UTF-8?q?s=20useEventListener/useTimeout=20=E4=B8=8E=20dayjs=20=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3=E6=89=8B=E5=86=99=E7=9B=91=E5=90=AC=E3=80=81=E6=92=A4?= =?UTF-8?q?=E9=94=80=E8=AE=A1=E6=97=B6=E4=B8=8E=E7=9B=B8=E5=AF=B9=E6=97=B6?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/components/BackTop.tsx | 11 ++++++----- .../admin/src/components/EditableCell/index.tsx | 17 ++++------------- apps/admin/src/pages/Notifications/index.tsx | 15 ++++++--------- 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/apps/admin/src/components/BackTop.tsx b/apps/admin/src/components/BackTop.tsx index 59ace443..43605234 100644 --- a/apps/admin/src/components/BackTop.tsx +++ b/apps/admin/src/components/BackTop.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Button, Tooltip } from 'antd'; import { VerticalAlignTopOutlined } from '@ant-design/icons'; +import { useEventCallback, useEventListener } from 'usehooks-ts'; /** * 全局「回到顶部」浮动按钮:长列表滚动超过 400px 后出现。 @@ -8,13 +9,13 @@ import { VerticalAlignTopOutlined } from '@ant-design/icons'; */ export const BackTop: React.FC<{ threshold?: number }> = ({ threshold = 400 }) => { const [visible, setVisible] = useState(false); + const updateVisible = useEventCallback(() => setVisible(window.scrollY > threshold)); useEffect(() => { - const onScroll = () => setVisible(window.scrollY > threshold); - onScroll(); - window.addEventListener('scroll', onScroll, { passive: true }); - return () => window.removeEventListener('scroll', onScroll); - }, [threshold]); + updateVisible(); + }, [threshold, updateVisible]); + + useEventListener('scroll', updateVisible, undefined, { passive: true }); if (!visible) return null; diff --git a/apps/admin/src/components/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx index 5e0f12fb..32bfe8c3 100644 --- a/apps/admin/src/components/EditableCell/index.tsx +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -3,6 +3,7 @@ import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd'; import dayjs, { type Dayjs } from 'dayjs'; import equal from 'fast-deep-equal'; import { usePermission } from '../../hooks/usePermission'; +import { useTimeout } from 'usehooks-ts'; import { useEditableCellStore } from '../../store/editableCell/editableCellStore'; import { message } from '../../ui/app-message'; import './style.css'; @@ -102,16 +103,10 @@ const EditableCell = ({ ); // 保存成功后短暂显示「撤销」入口:记录保存前的序列化旧值 const [undoMeta, setUndoMeta] = useState<{ serializedPrevious: unknown } | null>(null); - const undoTimerRef = useRef(undefined); + // 撤销入口 6 秒后自动消失;useTimeout 在 undoMeta 置空/组件卸载时自动清理 + useTimeout(() => setUndoMeta(null), undoMeta ? 6_000 : null); const enabled = !disabled && (!permission || hasPermission(permission)); - useEffect( - () => () => { - window.clearTimeout(undoTimerRef.current); - }, - [], - ); - const original = useMemo( () => serializeEditableValue( @@ -146,10 +141,8 @@ const EditableCell = ({ await onSave(parseValue ? parseValue(serialized) : (serialized as Value)); useEditableCellStore.getState().clearIfActive(idRef.current); setEditing(false); - // 提供 6 秒内的撤销入口(把旧值再保存一次) + // 提供 6 秒内的撤销入口(把旧值再保存一次);useTimeout 负责到时自动清除 setUndoMeta({ serializedPrevious: previousValue }); - window.clearTimeout(undoTimerRef.current); - undoTimerRef.current = window.setTimeout(() => setUndoMeta(null), 6_000); return true; } catch (error) { message.error(getErrorMessage(error, '保存失败')); @@ -210,7 +203,6 @@ const EditableCell = ({ } useEditableCellStore.getState().setActiveCell({ id: idRef.current, save }); // 重新进入编辑时清掉上一次的撤销入口 - window.clearTimeout(undoTimerRef.current); setUndoMeta(null); setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); setEditing(true); @@ -218,7 +210,6 @@ const EditableCell = ({ const handleUndo = async () => { if (!undoMeta) return; - window.clearTimeout(undoTimerRef.current); setUndoMeta(null); try { await onSave( diff --git a/apps/admin/src/pages/Notifications/index.tsx b/apps/admin/src/pages/Notifications/index.tsx index ab85cf49..7de21e09 100644 --- a/apps/admin/src/pages/Notifications/index.tsx +++ b/apps/admin/src/pages/Notifications/index.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; +import dayjs from 'dayjs'; import { validateResponse } from '../../utils/validate'; import { notificationsSchema } from '../../api/schemas'; import { List, Typography, Menu, Layout, Button, Spin, Space, Grid, Select } from 'antd'; @@ -43,15 +44,11 @@ const typeMap: Record = { }; function timeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return '刚刚'; - if (mins < 60) return `${mins}分钟前`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}小时前`; - const days = Math.floor(hours / 24); - if (days < 7) return `${days}天前`; - return new Date(dateStr).toLocaleDateString('zh-CN'); + const diff = Date.now() - dayjs(dateStr).valueOf(); + if (diff < 60_000) return '刚刚'; + // 7 天内用相对时间(dayjs relativeTime 已全局配置),更早显示具体日期 + if (diff < 7 * 86_400_000) return dayjs(dateStr).fromNow(); + return dayjs(dateStr).format('YYYY/M/D'); } const FILTER_ITEMS: Array<{ key: string; icon: React.ReactNode; label: string }> = [ From d324b2fb0a79e040ec0db96d4001253eaab108c4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:27:14 +0800 Subject: [PATCH 30/43] =?UTF-8?q?refactor(server):=20=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=BB=9F=E4=B8=80=E8=BF=81=E5=85=A5=20dayjs?= =?UTF-8?q?=EF=BC=88utc=20=E6=8F=92=E4=BB=B6=EF=BC=89=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E6=89=8B=E5=86=99=20Intl/padStart=20=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/package.json | 1 + .../agent-context/pending-tasks.service.ts | 14 ++------- .../attendance-generation.service.ts | 17 ++++------- .../attendance-settlement.service.ts | 18 +++++------ apps/server/src/attendance/attendance-time.ts | 14 ++++----- .../attendance/attendance.controller-base.ts | 7 ++--- apps/server/src/common/dayjs.ts | 7 +++++ apps/server/src/rbac/rbac-presets.ts | 30 ++++--------------- package-lock.json | 1 + 9 files changed, 37 insertions(+), 72 deletions(-) create mode 100644 apps/server/src/common/dayjs.ts diff --git a/apps/server/package.json b/apps/server/package.json index 7fc9b331..274eabe3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -45,6 +45,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "compression": "^1.8.1", + "dayjs": "^1.11.21", "dotenv": "^17.4.1", "exceljs": "^4.4.0", "express": "^5.2.1", diff --git a/apps/server/src/agent-context/pending-tasks.service.ts b/apps/server/src/agent-context/pending-tasks.service.ts index 18bf4448..c8f34de5 100644 --- a/apps/server/src/agent-context/pending-tasks.service.ts +++ b/apps/server/src/agent-context/pending-tasks.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import dayjs from '../common/dayjs'; import { DataSource } from 'typeorm'; import type { AgentToolContext } from '../agent-tools/agent-tool.types'; import type { StudentAccessScope } from '../students/student-access-scope'; @@ -55,20 +56,11 @@ function teacherScopedCountSql(sql: (scope: StudentAccessScope) => string): SqlB } function today(): string { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return dayjs().format('YYYY-MM-DD'); } function inDays(days: number): string { - const date = new Date(); - date.setDate(date.getDate() + days); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return dayjs().add(days, 'day').format('YYYY-MM-DD'); } const TASKS: readonly TaskDefinition[] = [ diff --git a/apps/server/src/attendance/attendance-generation.service.ts b/apps/server/src/attendance/attendance-generation.service.ts index 734b1a89..bf43f9f0 100644 --- a/apps/server/src/attendance/attendance-generation.service.ts +++ b/apps/server/src/attendance/attendance-generation.service.ts @@ -11,6 +11,7 @@ import { ScheduleType, } from '../entities'; import { toMinutes, isClassStudentActiveOnDate } from './attendance-time'; +import dayjs from '../common/dayjs'; import type { BatchCreateAttendanceDto, GenerateAttendanceFromSchedulesDto, GenerateFromSchedulesDto, SaveAttendancePeriodConfigsDto } from './dto/attendance.dto'; @Injectable() @@ -162,23 +163,15 @@ export class AttendanceGenerationService { } private getCourseClock(date: Date): { date: string; minutes: number } { - const parts = Object.fromEntries( - new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', hourCycle: 'h23', - }).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]), - ); + const c = dayjs.utc(date).add(8, 'hour'); return { - date: `${parts.year}-${parts.month}-${parts.day}`, - minutes: Number(parts.hour) * 60 + Number(parts.minute), + date: c.format('YYYY-MM-DD'), + minutes: c.hour() * 60 + c.minute(), }; } private shiftDate(date: string, days: number): string { - const shifted = new Date(`${date}T00:00:00.000Z`); - shifted.setUTCDate(shifted.getUTCDate() + days); - return shifted.toISOString().slice(0, 10); + return dayjs.utc(`${date}T00:00:00.000Z`).add(days, 'day').format('YYYY-MM-DD'); } private async ensureAttendancePeriodConfigs() { diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index c55086ee..553bf1fd 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import dayjs from '../common/dayjs'; import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; @@ -283,18 +284,13 @@ export class AttendanceSettlementService { } private getCourseClock(date: Date): { date: string; weekDay: number; minutes: number } { - const parts = Object.fromEntries( - new Intl.DateTimeFormat('en-CA', { - timeZone: this.courseTimeZone, - year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', - hour: '2-digit', minute: '2-digit', hourCycle: 'h23', - }).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]), - ); - const weekDays: Record = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }; + // Asia/Shanghai 无夏令时,固定 UTC+8 与 Intl 时区格式化等价 + const c = dayjs.utc(date).add(8, 'hour'); + const weekDay = c.day() === 0 ? 7 : c.day(); return { - date: `${parts.year}-${parts.month}-${parts.day}`, - weekDay: weekDays[parts.weekday], - minutes: Number(parts.hour) * 60 + Number(parts.minute), + date: c.format('YYYY-MM-DD'), + weekDay, + minutes: c.hour() * 60 + c.minute(), }; } diff --git a/apps/server/src/attendance/attendance-time.ts b/apps/server/src/attendance/attendance-time.ts index a38d8754..ac5fa791 100644 --- a/apps/server/src/attendance/attendance-time.ts +++ b/apps/server/src/attendance/attendance-time.ts @@ -1,22 +1,20 @@ +import dayjs from '../common/dayjs'; + export function toMinutes(time: string): number { const [hour, minute] = time.split(':').map(Number); return hour * 60 + minute; } export function getCourseClock(date: Date): { date: string; minutes: number } { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); + const d = dayjs(date); return { - date: `${year}-${month}-${day}`, - minutes: date.getHours() * 60 + date.getMinutes(), + date: d.format('YYYY-MM-DD'), + minutes: d.hour() * 60 + d.minute(), }; } export function shiftDate(date: string, days: number): string { - const d = new Date(`${date}T00:00:00.000Z`); - d.setUTCDate(d.getUTCDate() + days); - return d.toISOString().slice(0, 10); + return dayjs.utc(`${date}T00:00:00.000Z`).add(days, 'day').format('YYYY-MM-DD'); } export function mapLessonScheduleTimeToSession(startTime: string): string { diff --git a/apps/server/src/attendance/attendance.controller-base.ts b/apps/server/src/attendance/attendance.controller-base.ts index 491131d8..ea77673d 100644 --- a/apps/server/src/attendance/attendance.controller-base.ts +++ b/apps/server/src/attendance/attendance.controller-base.ts @@ -1,4 +1,5 @@ import { UseGuards } from '@nestjs/common'; +import dayjs from '../common/dayjs'; import { AttendanceService } from './attendance.service'; import { AttendanceImportService } from './attendance-import.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; @@ -32,11 +33,7 @@ export abstract class AttendanceControllerBase { ) {} protected getTodayDateOnly(): string { - const today = new Date(); - const year = today.getFullYear(); - const month = String(today.getMonth() + 1).padStart(2, '0'); - const day = String(today.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return dayjs().format('YYYY-MM-DD'); } protected canManageAllAttendance(req: { user: RequestUser }): boolean { diff --git a/apps/server/src/common/dayjs.ts b/apps/server/src/common/dayjs.ts new file mode 100644 index 00000000..d448c0bf --- /dev/null +++ b/apps/server/src/common/dayjs.ts @@ -0,0 +1,7 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; + +// 服务端统一 dayjs 实例:扩展 utc 插件以支持固定时区(Asia/Shanghai 无夏令时,恒为 UTC+8)。 +dayjs.extend(utc); + +export default dayjs; diff --git a/apps/server/src/rbac/rbac-presets.ts b/apps/server/src/rbac/rbac-presets.ts index e8deccc1..ea514f73 100644 --- a/apps/server/src/rbac/rbac-presets.ts +++ b/apps/server/src/rbac/rbac-presets.ts @@ -144,33 +144,13 @@ export const DEPRECATED_PERMISSION_CODES = [ ] as const; export const DEPRECATED_PERMISSION_CODE_SET = new Set(DEPRECATED_PERMISSION_CODES); +import dayjs from '../common/dayjs'; export function getChinaDateParts(date = new Date()): { date: string; weekDay: number } { - const parts = Object.fromEntries( - new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - weekday: 'short', - }) - .formatToParts(date) - .filter((part) => part.type !== 'literal') - .map((part) => [part.type, part.value]), - ); - const weekDays: Record = { - Mon: 1, - Tue: 2, - Wed: 3, - Thu: 4, - Fri: 5, - Sat: 6, - Sun: 7, - }; - return { - date: `${parts.year}-${parts.month}-${parts.day}`, - weekDay: weekDays[parts.weekday], - }; + // Asia/Shanghai 无夏令时,固定 UTC+8 与 Intl en-CA 格式化等价 + const c = dayjs.utc(date).add(8, 'hour'); + const weekDay = c.day() === 0 ? 7 : c.day(); + return { date: c.format('YYYY-MM-DD'), weekDay }; } export const PRESET_ROLES: Array<{ diff --git a/package-lock.json b/package-lock.json index b310c3a8..4d72a446 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,6 +108,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "compression": "^1.8.1", + "dayjs": "^1.11.21", "dotenv": "^17.4.1", "exceljs": "^4.4.0", "express": "^5.2.1", From 8ddc3ea6900a470bce60a56d549f618433b5ba02 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:27:14 +0800 Subject: [PATCH 31/43] =?UTF-8?q?refactor(admin):=20LiteMermaid=20?= =?UTF-8?q?=E7=94=A8=20usehooks-ts=20useIsMounted=20=E6=9B=BF=E4=BB=A3?= =?UTF-8?q?=E6=89=8B=E5=86=99=20cancelled=20=E6=A0=87=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/components/AiChat/LiteMermaid.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/admin/src/components/AiChat/LiteMermaid.tsx b/apps/admin/src/components/AiChat/LiteMermaid.tsx index be2e32ca..6bea6d67 100644 --- a/apps/admin/src/components/AiChat/LiteMermaid.tsx +++ b/apps/admin/src/components/AiChat/LiteMermaid.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { useIsMounted } from 'usehooks-ts'; interface LiteMermaidProps { children: string; @@ -11,9 +12,9 @@ interface LiteMermaidProps { export function LiteMermaid({ children }: LiteMermaidProps) { const containerRef = useRef(null); const [error, setError] = useState(null); + const isMounted = useIsMounted(); useEffect(() => { - let cancelled = false; const container = containerRef.current; if (!container) return; @@ -22,21 +23,17 @@ export function LiteMermaid({ children }: LiteMermaidProps) { const mermaid = (await import('mermaid')).default; mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' }); const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children); - if (!cancelled) { + if (isMounted()) { const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); container.replaceChildren(doc.documentElement); setError(null); } } catch (e) { - if (!cancelled) { + if (isMounted()) { setError(e instanceof Error ? e.message : '图表渲染失败'); } } })(); - - return () => { - cancelled = true; - }; }, [children]); if (error) { From 92ba2e779f1c039113633ea219d9c48578d48429 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:46:31 +0800 Subject: [PATCH 32/43] =?UTF-8?q?refactor(server):=20=E7=94=A8=E5=B7=B2?= =?UTF-8?q?=E8=A3=85=E5=BA=93=E6=9B=BF=E6=8D=A2=E6=89=8B=E5=86=99=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uuid v7 替换手写 RFC9562 实现(uuid@11.1.1 转为直接依赖) - node:timers/promises setTimeout 替换两处手写 sleep(钉钉限流、AI 上游重试退避) - dayjs 替换 4 处零散 Date 格式化(imports/expenses/occupancies 导入模板、档案报告日期) --- apps/server/package.json | 3 ++- apps/server/src/ai-chat/ai-chart.service.ts | 2 +- apps/server/src/ai-chat/ai-form.service.ts | 2 +- .../ai-chat/ai-model-stream.service.spec.ts | 7 ++++--- .../src/ai-chat/ai-model-stream.service.ts | 9 +++----- .../src/archive/archive-report.service.ts | 7 ++----- apps/server/src/common/uuid-v7.spec.ts | 11 ---------- apps/server/src/common/uuid-v7.ts | 21 ------------------- .../database/database-migrations.backfill.ts | 2 +- .../src/expenses/expenses.controller.ts | 6 ++---- apps/server/src/imports/imports.helpers.ts | 6 ++---- .../src/integration/dingtalk.service.ts | 6 ++---- .../occupancies/occupancy-import-template.ts | 6 ++---- .../organizations/organizations.service.ts | 2 +- package-lock.json | 3 ++- 15 files changed, 25 insertions(+), 68 deletions(-) delete mode 100644 apps/server/src/common/uuid-v7.spec.ts delete mode 100644 apps/server/src/common/uuid-v7.ts diff --git a/apps/server/package.json b/apps/server/package.json index 274eabe3..896065cf 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -62,7 +62,8 @@ "pino": "^10.3.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^0.3.31" + "typeorm": "^0.3.31", + "uuid": "^11.1.1" }, "devDependencies": { "@eslint/js": "^9.18.0", diff --git a/apps/server/src/ai-chat/ai-chart.service.ts b/apps/server/src/ai-chat/ai-chart.service.ts index bfbb112a..78688876 100644 --- a/apps/server/src/ai-chat/ai-chart.service.ts +++ b/apps/server/src/ai-chat/ai-chart.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { uuidV7 } from '../common/uuid-v7'; +import { v7 as uuidV7 } from 'uuid'; import type { AiReviewColumn, AiReviewRow } from './entities/ai-review.entity'; import { assertKeys, isPlainRecord, requireString } from './ai-validation'; diff --git a/apps/server/src/ai-chat/ai-form.service.ts b/apps/server/src/ai-chat/ai-form.service.ts index 74e3b2e9..0983e8c5 100644 --- a/apps/server/src/ai-chat/ai-form.service.ts +++ b/apps/server/src/ai-chat/ai-form.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; -import { uuidV7 } from '../common/uuid-v7'; +import { v7 as uuidV7 } from 'uuid'; import { AiForm, type AiFormField } from './entities/ai-form.entity'; import { isPlainRecord, requireString } from './ai-validation'; diff --git a/apps/server/src/ai-chat/ai-model-stream.service.spec.ts b/apps/server/src/ai-chat/ai-model-stream.service.spec.ts index 9bf91555..b4a6414e 100644 --- a/apps/server/src/ai-chat/ai-model-stream.service.spec.ts +++ b/apps/server/src/ai-chat/ai-model-stream.service.spec.ts @@ -1,6 +1,10 @@ import { AiModelStreamService } from './ai-model-stream.service'; import type { AiRuntimeConfig } from '../ai-config/dto/ai-config.dto'; +jest.mock('node:timers/promises', () => ({ + setTimeout: jest.fn().mockResolvedValue(undefined), +})); + const config: AiRuntimeConfig = { provider: 'DEEPSEEK' as AiRuntimeConfig['provider'], baseUrl: 'https://example.test/v1', @@ -57,7 +61,6 @@ describe('AiModelStreamService', () => { contentType: 'text/plain', body: body(), } as never); - jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never); const consume = async () => { for await (const _ of service.stream( config, @@ -90,7 +93,6 @@ describe('AiModelStreamService', () => { body: successBody(), } as never; }); - jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never); const events: Array<{ type: string; attempt?: number; maxRetries?: number; reason?: string }> = []; for await (const event of service.stream( config, @@ -116,7 +118,6 @@ describe('AiModelStreamService', () => { contentType: 'text/plain', body: body(), } as never); - jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never); const consume = async () => { for await (const _ of service.stream( config, diff --git a/apps/server/src/ai-chat/ai-model-stream.service.ts b/apps/server/src/ai-chat/ai-model-stream.service.ts index 018cd45e..fa60d61a 100644 --- a/apps/server/src/ai-chat/ai-model-stream.service.ts +++ b/apps/server/src/ai-chat/ai-model-stream.service.ts @@ -3,6 +3,7 @@ import { lookup } from 'node:dns'; import * as http from 'node:http'; import * as https from 'node:https'; import { isIP } from 'node:net'; +import { setTimeout as sleep } from 'node:timers/promises'; import type { AiRuntimeConfig } from '../ai-config/dto/ai-config.dto'; import { AiProvider } from '../ai-config/ai-config.entity'; import type { ModelMessage, ModelStreamEvent } from './ai-chat.types'; @@ -110,7 +111,7 @@ export class AiModelStreamService { delayMs, reason: error instanceof Error ? error.message : '网络连接失败', }; - await this.sleep(delayMs); + await sleep(delayMs); continue; } throw error; @@ -127,7 +128,7 @@ export class AiModelStreamService { delayMs, reason: `上游返回 ${response.status}`, }; - await this.sleep(delayMs); + await sleep(delayMs); continue; } const body = await this.readLimitedBody(response.body); @@ -220,10 +221,6 @@ export class AiModelStreamService { return /socket hang up|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(error.message); } - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - private safeUpstreamMessage(status: number, body: string): string { if (status === 401 || status === 403) return 'AI 服务认证失败'; if (status === 429) return 'AI 服务请求过于频繁'; diff --git a/apps/server/src/archive/archive-report.service.ts b/apps/server/src/archive/archive-report.service.ts index 5542940e..25ebfe14 100644 --- a/apps/server/src/archive/archive-report.service.ts +++ b/apps/server/src/archive/archive-report.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import dayjs from '../common/dayjs'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { StudentProfile } from '../entities/student-profile.entity'; @@ -65,11 +66,7 @@ export class ArchiveReportService { private buildHtml(data: ReportData): string { const { student, profile, enrollments, exams, learnings, result, attendances } = data; const name = student.name; - const now = new Date().toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); + const now = dayjs().format('YYYY年M月D日'); const sections = [ { diff --git a/apps/server/src/common/uuid-v7.spec.ts b/apps/server/src/common/uuid-v7.spec.ts deleted file mode 100644 index ae6a03a6..00000000 --- a/apps/server/src/common/uuid-v7.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { uuidV7 } from './uuid-v7'; - -describe('uuidV7', () => { - it('creates an RFC 9562 version 7 UUID with time-sortable prefixes', () => { - const first = uuidV7(1_700_000_000_000); - const second = uuidV7(1_700_000_000_001); - - expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); - expect(first < second).toBe(true); - }); -}); diff --git a/apps/server/src/common/uuid-v7.ts b/apps/server/src/common/uuid-v7.ts deleted file mode 100644 index 966c73c8..00000000 --- a/apps/server/src/common/uuid-v7.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -/** Generates an RFC 9562 UUIDv7 using the current Unix timestamp and cryptographic randomness. */ -export function uuidV7(now = Date.now()): string { - const bytes = Buffer.alloc(16); - const random = randomBytes(10); - let timestamp = BigInt(now); - - for (let index = 5; index >= 0; index -= 1) { - bytes[index] = Number(timestamp & 0xffn); - timestamp >>= 8n; - } - - bytes[6] = 0x70 | (random[0] & 0x0f); - bytes[7] = random[1]; - bytes[8] = 0x80 | (random[2] & 0x3f); - random.copy(bytes, 9, 3, 10); - - const hex = bytes.toString('hex'); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; -} diff --git a/apps/server/src/database/database-migrations.backfill.ts b/apps/server/src/database/database-migrations.backfill.ts index 078404df..e67eace1 100644 --- a/apps/server/src/database/database-migrations.backfill.ts +++ b/apps/server/src/database/database-migrations.backfill.ts @@ -1,6 +1,6 @@ import { Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { uuidV7 } from '../common/uuid-v7'; +import { v7 as uuidV7 } from 'uuid'; import { withQueryRunner } from './database-migrations.runner'; import { stringify } from '../common/stringify'; diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 60ad9091..7886b8c3 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -18,6 +18,7 @@ import { ValidationPipe, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; +import dayjs from '../common/dayjs'; import type { Response } from 'express'; import { ExpensesService } from './expenses.service'; import { @@ -92,10 +93,7 @@ function readCell(cell: ExcelJS.Cell | undefined): CellScalar { else return ''; } if (v instanceof Date) { - const y = v.getFullYear(); - const m = String(v.getMonth() + 1).padStart(2, '0'); - const d = String(v.getDate()).padStart(2, '0'); - return `${y}-${m}-${d}`; + return dayjs(v).format('YYYY-MM-DD'); } if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v; return v == null ? v : ''; diff --git a/apps/server/src/imports/imports.helpers.ts b/apps/server/src/imports/imports.helpers.ts index a4e9fe0a..39a4b8af 100644 --- a/apps/server/src/imports/imports.helpers.ts +++ b/apps/server/src/imports/imports.helpers.ts @@ -1,4 +1,5 @@ import * as ExcelJS from 'exceljs'; +import dayjs from '../common/dayjs'; import type { CellValue } from './imports.types'; export function parseJson(raw: string | null | undefined): T | null { @@ -52,10 +53,7 @@ export function cellValue(cell: ExcelJS.Cell | undefined): CellValue { export function parseDateValue(value: CellValue): string | null { if (value instanceof Date && !Number.isNaN(value.getTime())) { - const year = value.getFullYear(); - const month = String(value.getMonth() + 1).padStart(2, '0'); - const day = String(value.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return dayjs(value).format('YYYY-MM-DD'); } const raw = textValue(value); if (!raw) return null; diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index e25c0557..c91ee306 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -7,6 +7,7 @@ * - 用户同步(自动建 Student + StudentDingMapping) */ import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { setTimeout as sleep } from 'node:timers/promises'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; @@ -365,7 +366,7 @@ export class DingTalkService implements DingTalkServiceContext { // ═══════════════════════════════════════════ async rateLimit(): Promise { - await this.sleep(DingTalkService.MIN_INTERVAL); + await sleep(DingTalkService.MIN_INTERVAL); this.apiRequestCount++; } @@ -423,7 +424,4 @@ export class DingTalkService implements DingTalkServiceContext { return this.schedules.queryScheduleByUsers(...args); } - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } } diff --git a/apps/server/src/occupancies/occupancy-import-template.ts b/apps/server/src/occupancies/occupancy-import-template.ts index 12a00cc5..27eb608f 100644 --- a/apps/server/src/occupancies/occupancy-import-template.ts +++ b/apps/server/src/occupancies/occupancy-import-template.ts @@ -1,4 +1,5 @@ import * as ExcelJS from 'exceljs'; +import dayjs from '../common/dayjs'; export interface OccupancyImportRow { roomNumber: string; @@ -76,10 +77,7 @@ function parseDate(cell: ExcelJS.Cell | undefined): string { const value = cell?.value; if (!value) return ''; if (value instanceof Date) { - const year = value.getFullYear(); - const month = String(value.getMonth() + 1).padStart(2, '0'); - const day = String(value.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return dayjs(value).format('YYYY-MM-DD'); } const text = cellText(cell); const matched = text.match(/(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})/); diff --git a/apps/server/src/organizations/organizations.service.ts b/apps/server/src/organizations/organizations.service.ts index ee387786..71cc4796 100644 --- a/apps/server/src/organizations/organizations.service.ts +++ b/apps/server/src/organizations/organizations.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; -import { uuidV7 } from '../common/uuid-v7'; +import { v7 as uuidV7 } from 'uuid'; import { Organization } from '../entities/organization.entity'; import { Student } from '../entities/student.entity'; import { Occupancy } from '../entities/occupancy.entity'; diff --git a/package-lock.json b/package-lock.json index 4d72a446..4316f361 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,7 +125,8 @@ "pino": "^10.3.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^0.3.31" + "typeorm": "^0.3.31", + "uuid": "^11.1.1" }, "devDependencies": { "@eslint/js": "^9.18.0", From 8936453e84b5c995915a8b152530e83b318eeb54 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:52:32 +0800 Subject: [PATCH 33/43] =?UTF-8?q?refactor(server):=20=E6=97=A5=E6=9C=9F/?= =?UTF-8?q?=E6=9C=88=E4=BB=BD=E6=A0=BC=E5=BC=8F=E5=8C=96=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20dayjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换散落的 toISOString().slice(0,10) / split('T')[0] / replace('T',' ')(UTC 语义用 dayjs(...).utc() 保持完全一致) - Asia/Shanghai 固定时区日期(Intl.DateTimeFormat en-CA)改用 dayjs().utcOffset(8) - 月份边界 first/last、daysInMonth、nextMonth、shiftDate/addDays 等改 dayjs 简洁实现 - 涉及 attendance/classes/classrooms/room/dashboard/bills/expenses/occupancies/sync/ai-chat/rental 等 28 个文件 --- .../src/agent-tools/tools/tool-input.ts | 3 ++- .../src/ai-chat/ai-review.import-relations.ts | 3 ++- .../attendance/attendance-calendar.service.ts | 5 ++-- .../attendance-generation.service.ts | 6 ++--- .../attendance/attendance-import.service.ts | 3 ++- .../attendance-leave-sync.service.ts | 3 ++- .../attendance-records.controller.ts | 5 ++-- .../attendance/attendance-report.service.ts | 3 ++- .../attendance-settlement.service.ts | 4 +--- .../src/bills/bills-generation.service.ts | 5 ++-- .../src/classes/classes-queries.service.ts | 3 ++- apps/server/src/classes/classes.service.ts | 7 +++--- .../classroom-rentals.service.ts | 18 +++++---------- .../rental-schedule.service.ts | 23 ++++++++----------- .../src/classrooms/classrooms.service.ts | 19 ++++----------- .../dashboard/dashboard-queries.service.ts | 13 ++++------- .../server/src/dashboard/dashboard.service.ts | 16 +++---------- .../server/src/database/date-normalization.ts | 3 ++- .../expenses/expense-operations.service.ts | 3 ++- apps/server/src/expenses/expenses.service.ts | 3 ++- .../src/integration/dingtalk.attendance.ts | 3 ++- .../occupancies/occupancy-import.service.ts | 3 ++- .../occupancy-operations.service.ts | 3 ++- .../src/rooms/room-inspections.service.ts | 12 +++------- apps/server/src/rooms/room-query.service.ts | 8 ++----- apps/server/src/sync/schedule-sync.helpers.ts | 7 +++--- apps/server/src/sync/schedule-sync.service.ts | 3 ++- apps/server/src/sync/sync.service.ts | 7 +++--- 28 files changed, 82 insertions(+), 112 deletions(-) diff --git a/apps/server/src/agent-tools/tools/tool-input.ts b/apps/server/src/agent-tools/tools/tool-input.ts index d10e60aa..8159b1c4 100644 --- a/apps/server/src/agent-tools/tools/tool-input.ts +++ b/apps/server/src/agent-tools/tools/tool-input.ts @@ -1,4 +1,5 @@ import type { ToolInputResult } from '../agent-tool.types'; +import dayjs from '../../common/dayjs'; const FORBIDDEN_KEYS = new Set([ 'userId', 'isSuperAdmin', 'permissions', 'roles', 'ability', 'user', 'password', 'token', @@ -48,7 +49,7 @@ export function optionalDate(value: unknown, field: string): ToolInputResult { diff --git a/apps/server/src/attendance/attendance-records.controller.ts b/apps/server/src/attendance/attendance-records.controller.ts index 15493278..b02e09ca 100644 --- a/apps/server/src/attendance/attendance-records.controller.ts +++ b/apps/server/src/attendance/attendance-records.controller.ts @@ -6,6 +6,7 @@ import { AttendanceImportService } from './attendance-import.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AuthorizationService } from '../authorization'; import { logAudit } from '../common/with-audit-log'; +import dayjs from '../common/dayjs'; import { extractRequestInfo, type RequestInfoSource } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { @@ -195,11 +196,11 @@ export class AttendanceRecordsController extends AttendanceControllerBase { source: record.source || '', punchDevice: record.punchDeviceName || record.punchDeviceId || '', punchTime: record.punchTime - ? record.punchTime.toISOString().replace('T', ' ').substring(0, 19) + ? dayjs(record.punchTime).utc().format('YYYY-MM-DD HH:mm:ss') : '', remark: record.remark || '', createdAt: record.createdAt - ? record.createdAt.toISOString().replace('T', ' ').substring(0, 19) + ? dayjs(record.createdAt).utc().format('YYYY-MM-DD HH:mm:ss') : '', }); } diff --git a/apps/server/src/attendance/attendance-report.service.ts b/apps/server/src/attendance/attendance-report.service.ts index 79c877a2..bed66388 100644 --- a/apps/server/src/attendance/attendance-report.service.ts +++ b/apps/server/src/attendance/attendance-report.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { AttendanceRecord, AttendanceDevice } from '../entities'; import type { AttendanceReportQueryDto } from './dto/attendance.dto'; import { attachAttendanceDeviceMappings } from './attendance-device'; +import dayjs from '../common/dayjs'; @Injectable() export class AttendanceReportService { @@ -141,7 +142,7 @@ export class AttendanceReportService { async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); - const cutoffStr = cutoff.toISOString().slice(0, 10); + const cutoffStr = dayjs(cutoff).utc().format('YYYY-MM-DD'); const qb = this.attendanceRepo .createQueryBuilder('a') diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index 553bf1fd..46f1dafa 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -295,8 +295,6 @@ export class AttendanceSettlementService { } private shiftDate(date: string, days: number): string { - const shifted = new Date(`${date}T00:00:00.000Z`); - shifted.setUTCDate(shifted.getUTCDate() + days); - return shifted.toISOString().slice(0, 10); + return dayjs.utc(date).add(days, 'day').format('YYYY-MM-DD'); } } diff --git a/apps/server/src/bills/bills-generation.service.ts b/apps/server/src/bills/bills-generation.service.ts index cfcf2cc2..a780489b 100644 --- a/apps/server/src/bills/bills-generation.service.ts +++ b/apps/server/src/bills/bills-generation.service.ts @@ -4,6 +4,7 @@ import { DataSource, Repository } from 'typeorm'; import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room } from '../entities'; import { WalletsService } from '../wallets/wallets.service'; import type { GenerateBillsDto } from './dto/bill.dto'; +import dayjs from '../common/dayjs'; @Injectable() export class BillsGenerationService { @@ -235,7 +236,7 @@ export class BillsGenerationService { let year = startYear, month = startMonth; year < endYear || (year === endYear && month <= endMonth); ) { - const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); + const daysInMonth = dayjs.utc(`${year}-${month}`).daysInMonth(); const prefix = `${year}-${String(month).padStart(2, '0')}-`; const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`; const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`; @@ -258,7 +259,7 @@ export class BillsGenerationService { private isValidDate(value: string) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; const date = new Date(`${value}T00:00:00Z`); - return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; + return !Number.isNaN(date.getTime()) && dayjs(date).utc().format('YYYY-MM-DD') === value; } diff --git a/apps/server/src/classes/classes-queries.service.ts b/apps/server/src/classes/classes-queries.service.ts index 7e05d601..fcd93a65 100644 --- a/apps/server/src/classes/classes-queries.service.ts +++ b/apps/server/src/classes/classes-queries.service.ts @@ -5,6 +5,7 @@ import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entitie import { Classroom } from '../entities/classroom.entity'; import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto'; +import dayjs from '../common/dayjs'; interface AgentClassRow { id: string | number; @@ -80,7 +81,7 @@ export class ClassesQueriesService { const existingByStudentId = new Map( existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), ); - const today = new Date().toISOString().slice(0, 10); + const today = dayjs().utc().format('YYYY-MM-DD'); let skipped = 0; const memberships = studentIds.flatMap((studentId) => { const existing = existingByStudentId.get(studentId); diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index 42f34095..152e4855 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -22,6 +22,7 @@ StudentDingMapping } from '../entities'; import { ClassesQueriesService } from './classes-queries.service'; import { normalizeDateOnly } from '../database/date-normalization'; +import dayjs from '../common/dayjs'; import { CreateClassDto, UpdateClassDto, @@ -184,7 +185,7 @@ export class ClassesService { this.classStudentRepo.create({ classId: saved.id, studentId: sid, - joinDate: new Date().toISOString().split('T')[0], + joinDate: dayjs().utc().format('YYYY-MM-DD'), }), ); await this.classStudentRepo.save(entries); @@ -305,7 +306,7 @@ export class ClassesService { const existingByStudentId = new Map( existing.map((classStudent) => [classStudent.studentId, classStudent]), ); - const today = new Date().toISOString().split('T')[0]; + const today = dayjs().utc().format('YYYY-MM-DD'); let skipped = 0; const memberships = uniqueStudentIds.flatMap((studentId) => { const current = existingByStudentId.get(studentId); @@ -341,7 +342,7 @@ export class ClassesService { if (membership.status !== 'active') throw new BadRequestException('学生已离班'); membership.status = 'left'; - membership.leaveDate = new Date().toISOString().split('T')[0]; + membership.leaveDate = dayjs().utc().format('YYYY-MM-DD'); await this.classStudentRepo.save(membership); return { success: true }; } diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index 8cd9f4ca..242cf896 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -14,6 +14,7 @@ import { AttendanceRecord } from '../entities/attendance-record.entity'; import { AttendanceSession } from '../entities/attendance-session.entity'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; import { RentalScheduleService } from './rental-schedule.service'; +import dayjs from '../common/dayjs'; import * as path from 'path'; import * as fs from 'fs'; @@ -88,9 +89,8 @@ export class ClassroomRentalsService { qb.andWhere('r.lesseeOrganizationId = :oid', { oid: query.lesseeOrganizationId }); if (query?.month) { const [y, m] = query.month.split('-').map(Number); - const first = `${y}-${String(m).padStart(2, '0')}-01`; - const lastDay = new Date(y, m, 0).getDate(); - const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + const first = dayjs(`${y}-${m}-01`).format('YYYY-MM-DD'); + const last = dayjs(`${y}-${m}`).endOf('month').format('YYYY-MM-DD'); qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }); } if (!query?.includeEnded) { @@ -144,9 +144,8 @@ export class ClassroomRentalsService { } if (query?.month) { const [y, m] = query.month.split('-').map(Number); - const first = `${y}-${String(m).padStart(2, '0')}-01`; - const lastDay = new Date(y, m, 0).getDate(); - const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + const first = dayjs(`${y}-${m}-01`).format('YYYY-MM-DD'); + const last = dayjs(`${y}-${m}`).endOf('month').format('YYYY-MM-DD'); qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }); } if (!query?.includeEnded) { @@ -289,12 +288,7 @@ export class ClassroomRentalsService { if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) { throw new BadRequestException('仅有效租赁可以结束'); } - const today = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(new Date()); + const today = dayjs().utcOffset(8).format('YYYY-MM-DD'); if (rental.startDate > today) throw new BadRequestException('租赁尚未开始,不能结束'); await this.repo.update(id, { status: ClassroomRentalStatus.ENDED, diff --git a/apps/server/src/classroom-rentals/rental-schedule.service.ts b/apps/server/src/classroom-rentals/rental-schedule.service.ts index c1d6a2d0..321ab7b5 100644 --- a/apps/server/src/classroom-rentals/rental-schedule.service.ts +++ b/apps/server/src/classroom-rentals/rental-schedule.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { ClassroomRental, Classroom, ClassSchedule, ClassroomStatus } from '../entities'; import { ClassroomRentalStatus } from '../entities/classroom-rental.entity'; +import dayjs from '../common/dayjs'; const COLOR_PALETTE = [ "#5B8FF9", @@ -26,9 +27,8 @@ export class RentalScheduleService { ) {} async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) { - const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); - const monthStart = `${year}-${String(month).padStart(2, '0')}-01`; - const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + const monthStart = dayjs(`${year}-${month}-01`).format('YYYY-MM-DD'); + const monthEnd = dayjs(`${year}-${month}`).endOf('month').format('YYYY-MM-DD'); const [rentals, schedules] = await Promise.all([ this.repo.find({ @@ -135,7 +135,7 @@ export class RentalScheduleService { const current = this.toUtcDate(startDate); const end = this.toUtcDate(endDate); while (current <= end) { - dates.add(current.toISOString().slice(0, 10)); + dates.add(dayjs(current).utc().format('YYYY-MM-DD')); current.setUTCDate(current.getUTCDate() + 1); } } @@ -155,16 +155,16 @@ export class RentalScheduleService { const startWeekDay = current.getUTCDay() || 7; current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7)); while (current <= end) { - dates.add(current.toISOString().slice(0, 10)); + dates.add(dayjs(current).utc().format('YYYY-MM-DD')); current.setUTCDate(current.getUTCDate() + 7); } } async getSchedule(year: number, month: number) { - const lastDay = new Date(year, month, 0).getDate(); - const first = `${year}-${String(month).padStart(2, '0')}-01`; - const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + const first = dayjs(`${year}-${month}-01`).format('YYYY-MM-DD'); + const last = dayjs(`${year}-${month}`).endOf('month').format('YYYY-MM-DD'); + const lastDay = dayjs(`${year}-${month}`).daysInMonth(); const classrooms = await this.classroomRepo.find({ where: { status: Not(ClassroomStatus.ARCHIVED) }, @@ -285,12 +285,7 @@ export class RentalScheduleService { }; } withEffectiveStatus(rental: ClassroomRental) { - const today = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(new Date()); + const today = dayjs().utcOffset(8).format('YYYY-MM-DD'); const effectiveStatus = rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today ? ClassroomRentalStatus.ENDED diff --git a/apps/server/src/classrooms/classrooms.service.ts b/apps/server/src/classrooms/classrooms.service.ts index ddbb6077..a5974022 100644 --- a/apps/server/src/classrooms/classrooms.service.ts +++ b/apps/server/src/classrooms/classrooms.service.ts @@ -6,6 +6,7 @@ import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-re import { ClassSchedule } from '../entities/class-schedule.entity'; import { AttendanceDevice } from '../entities/attendance-device.entity'; import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto'; +import dayjs from '../common/dayjs'; /** getRawMany 原始行:驱动可能返回 string/number,date 列可能是 string 或 Date */ interface ScheduleUsageRawRow { @@ -173,12 +174,7 @@ export class ClassroomsService { } private async assertNoActiveAllocations(classroomId: number) { - const today = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(new Date()); + const today = dayjs().utcOffset(8).format('YYYY-MM-DD'); const scheduleCount = await this.scheduleRepo .createQueryBuilder('schedule') .where('schedule.classroomId = :classroomId', { classroomId }) @@ -226,12 +222,7 @@ export class ClassroomsService { if (classroomIds.length === 0) return result; const now = new Date(); - const todayStr = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(now); + const todayStr = dayjs(now).utcOffset(8).format('YYYY-MM-DD'); const currentTime = new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', @@ -401,7 +392,7 @@ export class ClassroomsService { const effStart = new Date(Math.max(new Date(r.startDate).getTime(), start.getTime())); const effEnd = new Date(Math.min(new Date(r.endDate).getTime(), end.getTime())); for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { - rentalDaysByRoom[r.classroomId].add(d.toISOString().slice(0, 10)); + rentalDaysByRoom[r.classroomId].add(dayjs(d).utc().format('YYYY-MM-DD')); } } @@ -410,7 +401,7 @@ export class ClassroomsService { const effStart = new Date(Math.max(new Date(s.startDate).getTime(), start.getTime())); const effEnd = new Date(Math.min(new Date(s.endDate).getTime(), end.getTime())); for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { - scheduleDaysByRoom[s.classroomId].add(d.toISOString().slice(0, 10)); + scheduleDaysByRoom[s.classroomId].add(dayjs(d).utc().format('YYYY-MM-DD')); } } diff --git a/apps/server/src/dashboard/dashboard-queries.service.ts b/apps/server/src/dashboard/dashboard-queries.service.ts index 5b4e06f5..6b8baad4 100644 --- a/apps/server/src/dashboard/dashboard-queries.service.ts +++ b/apps/server/src/dashboard/dashboard-queries.service.ts @@ -5,11 +5,10 @@ import { Occupancy } from '../entities/occupancy.entity'; import { Bill } from '../entities/bill.entity'; import { RoomExpense } from '../entities/room-expense.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; +import dayjs from '../common/dayjs'; export function nextMonth(ym: string): string { - const d = new Date(`${ym}-01`); - d.setMonth(d.getMonth() + 1); - return d.toISOString().slice(0, 7) + '-01'; + return dayjs.utc(`${ym}-01`).add(1, 'month').format('YYYY-MM-DD'); } export function applyClassScope( @@ -40,9 +39,7 @@ async getAttendanceTrend( todayStr: string, accessibleClassIds?: number[], ) { - const thirtyDaysAgo = new Date(todayStr); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29); - const startStr = thirtyDaysAgo.toISOString().slice(0, 10); + const startStr = dayjs.utc(todayStr).subtract(29, 'day').format('YYYY-MM-DD'); const trendQb = attendanceRepo .createQueryBuilder('a') @@ -82,9 +79,7 @@ async getIncomeTrend( const results: { month: string; amount: number }[] = []; for (let i = 5; i >= 0; i--) { - const d = new Date(`${currentMonth}-01`); - d.setMonth(d.getMonth() - i); - const m = d.toISOString().slice(0, 7); + const m = dayjs.utc(`${currentMonth}-01`).subtract(i, 'month').format('YYYY-MM'); const row = await billRepo .createQueryBuilder('b') diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index d9a82347..974dd972 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -10,6 +10,7 @@ import { Classroom } from '../entities/classroom.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { Class } from '../entities/class.entity'; +import dayjs from '../common/dayjs'; import { Deposit } from '../entities/deposit.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; @@ -277,9 +278,7 @@ export class DashboardService { } private nextMonth(ym: string): string { - const d = new Date(`${ym}-01`); - d.setMonth(d.getMonth() + 1); - return d.toISOString().slice(0, 7) + '-01'; + return dayjs.utc(`${ym}-01`).add(1, 'month').format('YYYY-MM-DD'); } async getClassroomOccupancy() { @@ -326,16 +325,7 @@ export class DashboardService { } private getChinaDate(date: Date): string { - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(date); - const values = Object.fromEntries( - parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]), - ); - return `${values.year}-${values.month}-${values.day}`; + return dayjs(date).utcOffset(8).format('YYYY-MM-DD'); } async getClassroomUtilizationStats() { diff --git a/apps/server/src/database/date-normalization.ts b/apps/server/src/database/date-normalization.ts index 21848478..5717b619 100644 --- a/apps/server/src/database/date-normalization.ts +++ b/apps/server/src/database/date-normalization.ts @@ -1,3 +1,4 @@ +import dayjs from '../common/dayjs'; const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const ISO_DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})T/; @@ -8,7 +9,7 @@ export function normalizeDateOnly(value?: string | null): string | null | undefi const isoPrefix = ISO_DATE_PREFIX_PATTERN.exec(value)?.[1]; if (isoPrefix) { const date = new Date(value); - if (!Number.isNaN(date.getTime())) return date.toISOString().slice(0, 10); + if (!Number.isNaN(date.getTime())) return dayjs(date).utc().format('YYYY-MM-DD'); } throw new Error(`无效日期格式: ${value}`); diff --git a/apps/server/src/expenses/expense-operations.service.ts b/apps/server/src/expenses/expense-operations.service.ts index 3dbcf1a5..f51c1771 100644 --- a/apps/server/src/expenses/expense-operations.service.ts +++ b/apps/server/src/expenses/expense-operations.service.ts @@ -5,6 +5,7 @@ import { DataSource, In, Repository } from 'typeorm'; import { RoomExpense, PersonalExpense, Room, Student } from '../entities'; import { BillsService } from '../bills/bills.service'; import { RoomsService } from '../rooms/rooms.service'; +import dayjs from '../common/dayjs'; import type { CreatePersonalExpenseDto } from './dto/expense.dto'; @Injectable() @@ -28,7 +29,7 @@ export class ExpenseOperationsService { private isValidDate(value: string) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; const date = new Date(`${value}T00:00:00Z`); - return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; + return !Number.isNaN(date.getTime()) && dayjs(date).utc().format('YYYY-MM-DD') === value; } async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) { diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index 1b0d666f..53fc9c02 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -12,6 +12,7 @@ import { CreateStudentUtilityBillDto, } from './dto/expense.dto'; import { BillsService } from '../bills/bills.service'; +import dayjs from '../common/dayjs'; import { ExpenseOperationsService } from './expense-operations.service'; /** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */ @@ -341,7 +342,7 @@ export class ExpensesService { private isValidDate(value: string) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; const date = new Date(`${value}T00:00:00Z`); - return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; + return !Number.isNaN(date.getTime()) && dayjs(date).utc().format('YYYY-MM-DD') === value; } async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) { diff --git a/apps/server/src/integration/dingtalk.attendance.ts b/apps/server/src/integration/dingtalk.attendance.ts index edc75ee7..afe9545b 100644 --- a/apps/server/src/integration/dingtalk.attendance.ts +++ b/apps/server/src/integration/dingtalk.attendance.ts @@ -2,6 +2,7 @@ import type { DingTalkAttendanceResult, DingTalkServiceContext, } from './dingtalk.types'; +import dayjs from '../common/dayjs'; export class DingTalkAttendanceClient { constructor(private readonly context: DingTalkServiceContext) {} @@ -52,7 +53,7 @@ export class DingTalkAttendanceClient { return records.map((r) => ({ userId: r.userId, userName: '', - workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).toISOString().slice(0, 10), + workDate: dayjs(r.workDate).utcOffset(8).format('YYYY-MM-DD'), timeResult: r.timeResult ?? r.sourceType ?? '', locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '', planCheckTime: '', diff --git a/apps/server/src/occupancies/occupancy-import.service.ts b/apps/server/src/occupancies/occupancy-import.service.ts index 0fe34f28..b6eea7ed 100644 --- a/apps/server/src/occupancies/occupancy-import.service.ts +++ b/apps/server/src/occupancies/occupancy-import.service.ts @@ -2,6 +2,7 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import { DataSource, IsNull, DeepPartial } from 'typeorm'; import { Occupancy, Room, Student, Deposit, Bed, Locker, Organization } from '../entities'; import { RoomsService } from '../rooms/rooms.service'; +import dayjs from '../common/dayjs'; class ImportRowSkipped extends Error {} @@ -132,7 +133,7 @@ export class OccupancyImportService { ); } - const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; + const checkInDate = row.checkInDate?.trim() || dayjs().utc().format('YYYY-MM-DD'); const checkOutDate = row.checkOutDate?.trim(); const billingStartDate = row.billingStartDate?.trim() || checkInDate; const isHistoricalRecord = Boolean(checkOutDate); diff --git a/apps/server/src/occupancies/occupancy-operations.service.ts b/apps/server/src/occupancies/occupancy-operations.service.ts index 749809c1..f0c503e6 100644 --- a/apps/server/src/occupancies/occupancy-operations.service.ts +++ b/apps/server/src/occupancies/occupancy-operations.service.ts @@ -6,6 +6,7 @@ import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; +import dayjs from '../common/dayjs'; import { Deposit } from '../entities/deposit.entity'; import { Organization } from '../entities/organization.entity'; import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; @@ -158,7 +159,7 @@ export class OccupancyOperationsService { const transferDate = new Date(dto.transferDate); const nextDay = new Date(transferDate); nextDay.setDate(nextDay.getDate() + 1); - const defaultBillingStart = nextDay.toISOString().split('T')[0]; + const defaultBillingStart = dayjs(nextDay).utc().format('YYYY-MM-DD'); this.assertDateOrder( dto.transferDate, dto.newBillingStartDate || defaultBillingStart, diff --git a/apps/server/src/rooms/room-inspections.service.ts b/apps/server/src/rooms/room-inspections.service.ts index 6b8d476b..4fadab13 100644 --- a/apps/server/src/rooms/room-inspections.service.ts +++ b/apps/server/src/rooms/room-inspections.service.ts @@ -8,6 +8,7 @@ import { RoomInspection } from '../entities/room-inspection.entity'; import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { occupancyWhereOnDate } from './room-occupancy-date'; +import dayjs from '../common/dayjs'; interface InspectorIdentity { id?: number; @@ -239,17 +240,10 @@ export class RoomInspectionsService implements OnApplicationBootstrap { } private getChinaDate(now: Date): string { - return new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(now); + return dayjs(now).utcOffset(8).format('YYYY-MM-DD'); } private shiftDate(date: string, days: number): string { - const shifted = new Date(`${date}T12:00:00Z`); - shifted.setUTCDate(shifted.getUTCDate() + days); - return shifted.toISOString().slice(0, 10); + return dayjs.utc(date).add(days, 'day').format('YYYY-MM-DD'); } } diff --git a/apps/server/src/rooms/room-query.service.ts b/apps/server/src/rooms/room-query.service.ts index 92f3ba1a..e5fddb21 100644 --- a/apps/server/src/rooms/room-query.service.ts +++ b/apps/server/src/rooms/room-query.service.ts @@ -7,6 +7,7 @@ import { Bed } from '../entities/bed.entity'; import { RoomInspectionsService } from './room-inspections.service'; import { occupancyWhereOnDate } from './room-occupancy-date'; import { parseRoomNumber } from './room-number'; +import dayjs from '../common/dayjs'; /** getRawMany 原始行:驱动可能返回 string 或 number,故标量字段用联合类型 */ interface RoomSearchRawRow { @@ -247,12 +248,7 @@ export class RoomQueryService { } private getChinaDate(now: Date): string { - return new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(now); + return dayjs(now).utcOffset(8).format('YYYY-MM-DD'); } diff --git a/apps/server/src/sync/schedule-sync.helpers.ts b/apps/server/src/sync/schedule-sync.helpers.ts index 57281514..559cbee8 100644 --- a/apps/server/src/sync/schedule-sync.helpers.ts +++ b/apps/server/src/sync/schedule-sync.helpers.ts @@ -1,6 +1,7 @@ import { Repository, In } from 'typeorm'; import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities'; import type { DingTalkScheduleItem } from '../integration/dingtalk.service'; +import dayjs from '../common/dayjs'; export interface DailySchedulePeriod { startTime: string; @@ -97,7 +98,7 @@ export function buildDailySchedulePlans( const toDate = new Date(`${syncTo}T00:00:00.000Z`); for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) { - const dateStr = date.toISOString().slice(0, 10); + const dateStr = dayjs(date).utc().format('YYYY-MM-DD'); const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay(); for (const schedule of schedules) { @@ -179,7 +180,5 @@ export function minutesBetween(startTime: string, endTime: string): number { } export function addDays(dateStr: string, days: number): string { - const d = new Date(`${dateStr}T00:00:00.000Z`); - d.setUTCDate(d.getUTCDate() + days); - return d.toISOString().slice(0, 10); + return dayjs.utc(dateStr).add(days, 'day').format('YYYY-MM-DD'); } diff --git a/apps/server/src/sync/schedule-sync.service.ts b/apps/server/src/sync/schedule-sync.service.ts index a3c4d59a..8fa3f8c4 100644 --- a/apps/server/src/sync/schedule-sync.service.ts +++ b/apps/server/src/sync/schedule-sync.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities'; import { DingTalkService } from '../integration/dingtalk.service'; +import dayjs from '../common/dayjs'; import { buildClassDingUserMap, loadClassNames, @@ -45,7 +46,7 @@ export class ScheduleSyncService { opUserId = 'manager', attendanceMachineOnly = false, ): Promise { - const startDate = dateFrom || new Date().toISOString().slice(0, 10); + const startDate = dateFrom || dayjs().utc().format('YYYY-MM-DD'); const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30; const endDate = addDays(startDate, normalizedDays - 1); diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index b953de67..ed6ed2f4 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -10,6 +10,7 @@ import { WeComService } from '../integration/wecom.service'; import { JinshujuService } from '../integration/jinshuju.service'; import { syncJinshujuStudents } from '../integration/jinshuju-student-sync'; import { ScheduleSyncService } from './schedule-sync.service'; +import dayjs from '../common/dayjs'; import { SyncRunner } from './sync-runner'; import { getMatchRule, validateMatchRule, extractField } from './jinshuju-rules'; @@ -70,8 +71,8 @@ export class SyncService { } const result = await this.attendanceImportService.importFromDingTalk({ - startDate: startDate.toISOString().slice(0, 10), - endDate: endDate.toISOString().slice(0, 10), + startDate: dayjs(startDate).utc().format('YYYY-MM-DD'), + endDate: dayjs(endDate).utc().format('YYYY-MM-DD'), userIds, autoMatch: true, }); @@ -304,7 +305,7 @@ export class SyncService { } async getScheduleSyncStatus(date?: string) { - return this.scheduleSyncService.getStatus(date || new Date().toISOString().slice(0, 10)); + return this.scheduleSyncService.getStatus(date || dayjs().utc().format('YYYY-MM-DD')); } /** From 782b43ef0aeb6382d759d706f897a27beec91842 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:56:00 +0800 Subject: [PATCH 34/43] =?UTF-8?q?fix(attendance):=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=91=A8=E8=8C=83=E5=9B=B4=E6=94=B9=E4=B8=BA=E4=B8=AD=E5=9B=BD?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E5=91=A8=E4=B8=80=E8=B5=B7=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateFromSchedules / getCalendar 原来用本地周一零点转 UTC 日期, 在上海时区会得到前一天的日期,导致默认范围变成周日~周日(8 天)。 改为 dayjs().utcOffset(8) 按中国日期计算,与 getTodayDateOnly 语义一致。 新增测试锁定:默认周 = 本周一~本周日,周日晚上仍归本周,显式传参原样透传。 --- .../attendance-calendar.service.spec.ts | 44 +++++++++++++ .../attendance/attendance-calendar.service.ts | 12 ++-- .../attendance-generation.service.spec.ts | 65 +++++++++++++++++++ .../attendance-generation.service.ts | 19 ++---- 4 files changed, 121 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/attendance/attendance-calendar.service.spec.ts create mode 100644 apps/server/src/attendance/attendance-generation.service.spec.ts diff --git a/apps/server/src/attendance/attendance-calendar.service.spec.ts b/apps/server/src/attendance/attendance-calendar.service.spec.ts new file mode 100644 index 00000000..c60cd861 --- /dev/null +++ b/apps/server/src/attendance/attendance-calendar.service.spec.ts @@ -0,0 +1,44 @@ +import { AttendanceCalendarService } from './attendance-calendar.service'; + +describe('AttendanceCalendarService — 默认周起点', () => { + function createService() { + return new AttendanceCalendarService({} as never, {} as never); + } + + afterEach(() => jest.useRealTimers()); + + it('未传 weekStart 时默认本周一(中国日期)', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-05T10:00:00+08:00')); // 周三 + const service = createService(); + const spy = jest + .spyOn(service as never, 'buildCalendar' as never) + .mockResolvedValue([] as never); + + await service.getCalendar({ classId: 1 } as never); + + expect(spy).toHaveBeenCalledWith(1, '2026-08-03'); + }); + + it('周日晚间仍归入本周一', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-09T22:00:00+08:00')); // 周日晚上 + const service = createService(); + const spy = jest + .spyOn(service as never, 'buildCalendar' as never) + .mockResolvedValue([] as never); + + await service.getCalendar({ classId: 1 } as never); + + expect(spy).toHaveBeenCalledWith(1, '2026-08-03'); + }); + + it('传入 weekStart 时原样透传', async () => { + const service = createService(); + const spy = jest + .spyOn(service as never, 'buildCalendar' as never) + .mockResolvedValue([] as never); + + await service.getCalendar({ classId: 1, weekStart: '2026-08-10' } as never); + + expect(spy).toHaveBeenCalledWith(1, '2026-08-10'); + }); +}); diff --git a/apps/server/src/attendance/attendance-calendar.service.ts b/apps/server/src/attendance/attendance-calendar.service.ts index a359b84a..3e73a5af 100644 --- a/apps/server/src/attendance/attendance-calendar.service.ts +++ b/apps/server/src/attendance/attendance-calendar.service.ts @@ -18,13 +18,11 @@ export class AttendanceCalendarService { const { classId, weekStart } = query; if (!weekStart) { - // Default to the Monday of the current week - const now = new Date(); - const day = now.getDay(); - const diff = day === 0 ? -6 : 1 - day; // Monday offset - const monday = new Date(now); - monday.setDate(now.getDate() + diff); - const mondayStr = dayjs(monday).utc().format('YYYY-MM-DD'); + // Default to the Monday of the current week (China calendar) + const now = dayjs().utcOffset(8); + const day = now.day(); + const monday = now.subtract(day === 0 ? 6 : day - 1, 'day'); + const mondayStr = monday.format('YYYY-MM-DD'); return this.buildCalendar(classId, mondayStr); } diff --git a/apps/server/src/attendance/attendance-generation.service.spec.ts b/apps/server/src/attendance/attendance-generation.service.spec.ts new file mode 100644 index 00000000..ac08d136 --- /dev/null +++ b/apps/server/src/attendance/attendance-generation.service.spec.ts @@ -0,0 +1,65 @@ +import { AttendanceGenerationService } from './attendance-generation.service'; + +describe('AttendanceGenerationService — 默认周范围', () => { + function createService() { + return new AttendanceGenerationService( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + } + + afterEach(() => jest.useRealTimers()); + + it('未传 startDate/endDate 时默认生成本周一~本周日(中国日期)', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-05T10:00:00+08:00')); // 周三 + const service = createService(); + const spy = jest + .spyOn(service as never, 'generateAttendanceFromSchedules' as never) + .mockResolvedValue({ count: 0, records: [] } as never); + + await service.generateFromSchedules({ classId: 1 } as never); + + expect(spy).toHaveBeenCalledWith({ + classId: 1, + dateFrom: '2026-08-03', + dateTo: '2026-08-09', + }); + }); + + it('周日晚间仍归入本周(周一起点)', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-09T22:00:00+08:00')); // 周日晚上 + const service = createService(); + const spy = jest + .spyOn(service as never, 'generateAttendanceFromSchedules' as never) + .mockResolvedValue({ count: 0, records: [] } as never); + + await service.generateFromSchedules({ classId: 1 } as never); + + expect(spy).toHaveBeenCalledWith({ + classId: 1, + dateFrom: '2026-08-03', + dateTo: '2026-08-09', + }); + }); + + it('传入 startDate/endDate 时原样透传', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-05T10:00:00+08:00')); + const service = createService(); + const spy = jest + .spyOn(service as never, 'generateAttendanceFromSchedules' as never) + .mockResolvedValue({ count: 0, records: [] } as never); + + await service.generateFromSchedules({ classId: 1, startDate: '2026-08-01', endDate: '2026-08-31' } as never); + + expect(spy).toHaveBeenCalledWith({ + classId: 1, + dateFrom: '2026-08-01', + dateTo: '2026-08-31', + }); + }); +}); diff --git a/apps/server/src/attendance/attendance-generation.service.ts b/apps/server/src/attendance/attendance-generation.service.ts index cbfc2858..b1dd7cfd 100644 --- a/apps/server/src/attendance/attendance-generation.service.ts +++ b/apps/server/src/attendance/attendance-generation.service.ts @@ -136,19 +136,14 @@ export class AttendanceGenerationService { ): Promise<{ count: number; records: AttendanceRecord[] }> { const { classId, startDate, endDate } = dto; - // Default to current week (Monday–Sunday) - const now = new Date(); - const dayOfWeek = now.getDay(); - const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; - const monday = new Date(now); - monday.setDate(now.getDate() + mondayOffset); - monday.setHours(0, 0, 0, 0); - const sunday = new Date(monday); - sunday.setDate(monday.getDate() + 6); - sunday.setHours(23, 59, 59, 999); + // Default to current week (Monday–Sunday), China calendar + const now = dayjs().utcOffset(8); + const day = now.day(); + const monday = now.subtract(day === 0 ? 6 : day - 1, 'day').startOf('day'); + const sunday = monday.add(6, 'day').endOf('day'); - const dateFrom = startDate ?? dayjs(monday).utc().format('YYYY-MM-DD'); - const dateTo = endDate ?? dayjs(sunday).utc().format('YYYY-MM-DD'); + const dateFrom = startDate ?? monday.format('YYYY-MM-DD'); + const dateTo = endDate ?? sunday.format('YYYY-MM-DD'); return this.generateAttendanceFromSchedules({ classId, From 7905f1ee23b2e41422ec6efb56f9fbd723b5d506 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:58:16 +0800 Subject: [PATCH 35/43] =?UTF-8?q?fix(server):=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E6=97=A5=E6=9C=9F'=E4=BB=8A=E5=A4=A9'=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=BA=E4=B8=AD=E5=9B=BD=E6=97=A5=E6=9C=9F=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E5=87=8C=E6=99=A8=20UTC=20=E5=81=8F=E4=B8=80=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit joinDate/leaveDate/checkInDate/sync 起始日等业务日期默认值原来用 dayjs().utc()(UTC 今天),在国内 00:00-07:59 会取到昨天。 统一改为 dayjs().utcOffset(8)(固定 UTC+8 中国日期),与考勤周边界 修复保持一致;pending-tasks/controller-base 的本地 dayjs() 也显式化。 --- apps/server/src/agent-context/pending-tasks.service.ts | 4 ++-- apps/server/src/ai-chat/ai-review.import-relations.ts | 2 +- apps/server/src/attendance/attendance.controller-base.ts | 2 +- apps/server/src/classes/classes-queries.service.ts | 2 +- apps/server/src/classes/classes.service.ts | 6 +++--- apps/server/src/occupancies/occupancy-import.service.ts | 2 +- apps/server/src/sync/schedule-sync.service.ts | 2 +- apps/server/src/sync/sync.service.ts | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/server/src/agent-context/pending-tasks.service.ts b/apps/server/src/agent-context/pending-tasks.service.ts index c8f34de5..9593a50a 100644 --- a/apps/server/src/agent-context/pending-tasks.service.ts +++ b/apps/server/src/agent-context/pending-tasks.service.ts @@ -56,11 +56,11 @@ function teacherScopedCountSql(sql: (scope: StudentAccessScope) => string): SqlB } function today(): string { - return dayjs().format('YYYY-MM-DD'); + return dayjs().utcOffset(8).format('YYYY-MM-DD'); } function inDays(days: number): string { - return dayjs().add(days, 'day').format('YYYY-MM-DD'); + return dayjs().utcOffset(8).add(days, 'day').format('YYYY-MM-DD'); } const TASKS: readonly TaskDefinition[] = [ diff --git a/apps/server/src/ai-chat/ai-review.import-relations.ts b/apps/server/src/ai-chat/ai-review.import-relations.ts index ea6c3344..de666530 100644 --- a/apps/server/src/ai-chat/ai-review.import-relations.ts +++ b/apps/server/src/ai-chat/ai-review.import-relations.ts @@ -250,7 +250,7 @@ async function importCheckins( continue; } - const checkInDate = toDateString(row.checkInDate) ?? dayjs().utc().format('YYYY-MM-DD'); + const checkInDate = toDateString(row.checkInDate) ?? dayjs().utcOffset(8).format('YYYY-MM-DD'); const billingStartDate = toDateString(row.billingStartDate) ?? checkInDate; const checkOutDate = toDateString(row.checkOutDate); const isHistoricalRecord = Boolean(checkOutDate); diff --git a/apps/server/src/attendance/attendance.controller-base.ts b/apps/server/src/attendance/attendance.controller-base.ts index ea77673d..8eb02338 100644 --- a/apps/server/src/attendance/attendance.controller-base.ts +++ b/apps/server/src/attendance/attendance.controller-base.ts @@ -33,7 +33,7 @@ export abstract class AttendanceControllerBase { ) {} protected getTodayDateOnly(): string { - return dayjs().format('YYYY-MM-DD'); + return dayjs().utcOffset(8).format('YYYY-MM-DD'); } protected canManageAllAttendance(req: { user: RequestUser }): boolean { diff --git a/apps/server/src/classes/classes-queries.service.ts b/apps/server/src/classes/classes-queries.service.ts index fcd93a65..50c42caf 100644 --- a/apps/server/src/classes/classes-queries.service.ts +++ b/apps/server/src/classes/classes-queries.service.ts @@ -81,7 +81,7 @@ export class ClassesQueriesService { const existingByStudentId = new Map( existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), ); - const today = dayjs().utc().format('YYYY-MM-DD'); + const today = dayjs().utcOffset(8).format('YYYY-MM-DD'); let skipped = 0; const memberships = studentIds.flatMap((studentId) => { const existing = existingByStudentId.get(studentId); diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index 152e4855..05084384 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -185,7 +185,7 @@ export class ClassesService { this.classStudentRepo.create({ classId: saved.id, studentId: sid, - joinDate: dayjs().utc().format('YYYY-MM-DD'), + joinDate: dayjs().utcOffset(8).format('YYYY-MM-DD'), }), ); await this.classStudentRepo.save(entries); @@ -306,7 +306,7 @@ export class ClassesService { const existingByStudentId = new Map( existing.map((classStudent) => [classStudent.studentId, classStudent]), ); - const today = dayjs().utc().format('YYYY-MM-DD'); + const today = dayjs().utcOffset(8).format('YYYY-MM-DD'); let skipped = 0; const memberships = uniqueStudentIds.flatMap((studentId) => { const current = existingByStudentId.get(studentId); @@ -342,7 +342,7 @@ export class ClassesService { if (membership.status !== 'active') throw new BadRequestException('学生已离班'); membership.status = 'left'; - membership.leaveDate = dayjs().utc().format('YYYY-MM-DD'); + membership.leaveDate = dayjs().utcOffset(8).format('YYYY-MM-DD'); await this.classStudentRepo.save(membership); return { success: true }; } diff --git a/apps/server/src/occupancies/occupancy-import.service.ts b/apps/server/src/occupancies/occupancy-import.service.ts index b6eea7ed..8723378d 100644 --- a/apps/server/src/occupancies/occupancy-import.service.ts +++ b/apps/server/src/occupancies/occupancy-import.service.ts @@ -133,7 +133,7 @@ export class OccupancyImportService { ); } - const checkInDate = row.checkInDate?.trim() || dayjs().utc().format('YYYY-MM-DD'); + const checkInDate = row.checkInDate?.trim() || dayjs().utcOffset(8).format('YYYY-MM-DD'); const checkOutDate = row.checkOutDate?.trim(); const billingStartDate = row.billingStartDate?.trim() || checkInDate; const isHistoricalRecord = Boolean(checkOutDate); diff --git a/apps/server/src/sync/schedule-sync.service.ts b/apps/server/src/sync/schedule-sync.service.ts index 8fa3f8c4..837b3114 100644 --- a/apps/server/src/sync/schedule-sync.service.ts +++ b/apps/server/src/sync/schedule-sync.service.ts @@ -46,7 +46,7 @@ export class ScheduleSyncService { opUserId = 'manager', attendanceMachineOnly = false, ): Promise { - const startDate = dateFrom || dayjs().utc().format('YYYY-MM-DD'); + const startDate = dateFrom || dayjs().utcOffset(8).format('YYYY-MM-DD'); const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30; const endDate = addDays(startDate, normalizedDays - 1); diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index ed6ed2f4..e1bf143d 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -305,7 +305,7 @@ export class SyncService { } async getScheduleSyncStatus(date?: string) { - return this.scheduleSyncService.getStatus(date || dayjs().utc().format('YYYY-MM-DD')); + return this.scheduleSyncService.getStatus(date || dayjs().utcOffset(8).format('YYYY-MM-DD')); } /** From 4dd7b5a0b195f4895f1552a389910b53856cbbb2 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 18:01:35 +0800 Subject: [PATCH 36/43] =?UTF-8?q?fix(server):=20=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=88=B3=E2=86=92=E4=B8=9A=E5=8A=A1=E6=97=A5=E6=9C=9F/?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E5=85=A8=E9=83=A8=E7=BB=9F=E4=B8=80=E4=B8=BA?= =?UTF-8?q?=E4=B8=AD=E5=9B=BD=E6=97=B6=E5=8C=BA=EF=BC=88UTC+8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前仅'今天'默认值统一为中国日期,Date→字符串的转换仍是 UTC: - sync 考勤同步窗口 lastSyncAt 时间戳按 UTC 取日期,凌晨会偏一天 - 考勤导出 punchTime/createdAt 显示 UTC 时间 - normalizeDateOnly 把 ISO datetime 按 UTC 归一化,业务日期少一天 - getCourseClock/getChinaDateParts 用 dayjs.utc(date).add(8h) 隐式转换 统一为 dayjs(date).utcOffset(8),纯日期字符串运算(shiftDate/addDays/ daysInMonth/nextMonth)保留 dayjs.utc;date-normalization 测试断言 同步更新为北京时间语义。 --- apps/server/src/agent-tools/tools/tool-input.ts | 2 +- apps/server/src/attendance/attendance-calendar.service.ts | 2 +- apps/server/src/attendance/attendance-generation.service.ts | 4 ++-- apps/server/src/attendance/attendance-import.service.ts | 2 +- apps/server/src/attendance/attendance-leave-sync.service.ts | 2 +- apps/server/src/attendance/attendance-records.controller.ts | 4 ++-- apps/server/src/attendance/attendance-report.service.ts | 2 +- apps/server/src/attendance/attendance-settlement.service.ts | 2 +- apps/server/src/bills/bills-generation.service.ts | 2 +- apps/server/src/classroom-rentals/rental-schedule.service.ts | 4 ++-- apps/server/src/classrooms/classrooms.service.ts | 4 ++-- apps/server/src/database/date-normalization.spec.ts | 5 +++-- apps/server/src/database/date-normalization.ts | 2 +- apps/server/src/expenses/expense-operations.service.ts | 2 +- apps/server/src/expenses/expenses.service.ts | 2 +- apps/server/src/occupancies/occupancy-operations.service.ts | 2 +- apps/server/src/rbac/rbac-presets.ts | 2 +- apps/server/src/sync/schedule-sync.helpers.ts | 2 +- apps/server/src/sync/sync.service.ts | 4 ++-- 19 files changed, 26 insertions(+), 25 deletions(-) diff --git a/apps/server/src/agent-tools/tools/tool-input.ts b/apps/server/src/agent-tools/tools/tool-input.ts index 8159b1c4..04d353fb 100644 --- a/apps/server/src/agent-tools/tools/tool-input.ts +++ b/apps/server/src/agent-tools/tools/tool-input.ts @@ -49,7 +49,7 @@ export function optionalDate(value: unknown, field: string): ToolInputResult { diff --git a/apps/server/src/attendance/attendance-records.controller.ts b/apps/server/src/attendance/attendance-records.controller.ts index b02e09ca..4a6b46f4 100644 --- a/apps/server/src/attendance/attendance-records.controller.ts +++ b/apps/server/src/attendance/attendance-records.controller.ts @@ -196,11 +196,11 @@ export class AttendanceRecordsController extends AttendanceControllerBase { source: record.source || '', punchDevice: record.punchDeviceName || record.punchDeviceId || '', punchTime: record.punchTime - ? dayjs(record.punchTime).utc().format('YYYY-MM-DD HH:mm:ss') + ? dayjs(record.punchTime).utcOffset(8).format('YYYY-MM-DD HH:mm:ss') : '', remark: record.remark || '', createdAt: record.createdAt - ? dayjs(record.createdAt).utc().format('YYYY-MM-DD HH:mm:ss') + ? dayjs(record.createdAt).utcOffset(8).format('YYYY-MM-DD HH:mm:ss') : '', }); } diff --git a/apps/server/src/attendance/attendance-report.service.ts b/apps/server/src/attendance/attendance-report.service.ts index bed66388..72108e31 100644 --- a/apps/server/src/attendance/attendance-report.service.ts +++ b/apps/server/src/attendance/attendance-report.service.ts @@ -142,7 +142,7 @@ export class AttendanceReportService { async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); - const cutoffStr = dayjs(cutoff).utc().format('YYYY-MM-DD'); + const cutoffStr = dayjs(cutoff).utcOffset(8).format('YYYY-MM-DD'); const qb = this.attendanceRepo .createQueryBuilder('a') diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index 46f1dafa..4d474c9a 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -285,7 +285,7 @@ export class AttendanceSettlementService { private getCourseClock(date: Date): { date: string; weekDay: number; minutes: number } { // Asia/Shanghai 无夏令时,固定 UTC+8 与 Intl 时区格式化等价 - const c = dayjs.utc(date).add(8, 'hour'); + const c = dayjs(date).utcOffset(8); const weekDay = c.day() === 0 ? 7 : c.day(); return { date: c.format('YYYY-MM-DD'), diff --git a/apps/server/src/bills/bills-generation.service.ts b/apps/server/src/bills/bills-generation.service.ts index a780489b..5861fd2e 100644 --- a/apps/server/src/bills/bills-generation.service.ts +++ b/apps/server/src/bills/bills-generation.service.ts @@ -259,7 +259,7 @@ export class BillsGenerationService { private isValidDate(value: string) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; const date = new Date(`${value}T00:00:00Z`); - return !Number.isNaN(date.getTime()) && dayjs(date).utc().format('YYYY-MM-DD') === value; + return !Number.isNaN(date.getTime()) && dayjs(date).utcOffset(8).format('YYYY-MM-DD') === value; } diff --git a/apps/server/src/classroom-rentals/rental-schedule.service.ts b/apps/server/src/classroom-rentals/rental-schedule.service.ts index 321ab7b5..15cf14f5 100644 --- a/apps/server/src/classroom-rentals/rental-schedule.service.ts +++ b/apps/server/src/classroom-rentals/rental-schedule.service.ts @@ -135,7 +135,7 @@ export class RentalScheduleService { const current = this.toUtcDate(startDate); const end = this.toUtcDate(endDate); while (current <= end) { - dates.add(dayjs(current).utc().format('YYYY-MM-DD')); + dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD')); current.setUTCDate(current.getUTCDate() + 1); } } @@ -155,7 +155,7 @@ export class RentalScheduleService { const startWeekDay = current.getUTCDay() || 7; current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7)); while (current <= end) { - dates.add(dayjs(current).utc().format('YYYY-MM-DD')); + dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD')); current.setUTCDate(current.getUTCDate() + 7); } } diff --git a/apps/server/src/classrooms/classrooms.service.ts b/apps/server/src/classrooms/classrooms.service.ts index a5974022..215062a7 100644 --- a/apps/server/src/classrooms/classrooms.service.ts +++ b/apps/server/src/classrooms/classrooms.service.ts @@ -392,7 +392,7 @@ export class ClassroomsService { const effStart = new Date(Math.max(new Date(r.startDate).getTime(), start.getTime())); const effEnd = new Date(Math.min(new Date(r.endDate).getTime(), end.getTime())); for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { - rentalDaysByRoom[r.classroomId].add(dayjs(d).utc().format('YYYY-MM-DD')); + rentalDaysByRoom[r.classroomId].add(dayjs(d).utcOffset(8).format('YYYY-MM-DD')); } } @@ -401,7 +401,7 @@ export class ClassroomsService { const effStart = new Date(Math.max(new Date(s.startDate).getTime(), start.getTime())); const effEnd = new Date(Math.min(new Date(s.endDate).getTime(), end.getTime())); for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { - scheduleDaysByRoom[s.classroomId].add(dayjs(d).utc().format('YYYY-MM-DD')); + scheduleDaysByRoom[s.classroomId].add(dayjs(d).utcOffset(8).format('YYYY-MM-DD')); } } diff --git a/apps/server/src/database/date-normalization.spec.ts b/apps/server/src/database/date-normalization.spec.ts index a9ff829a..df17a245 100644 --- a/apps/server/src/database/date-normalization.spec.ts +++ b/apps/server/src/database/date-normalization.spec.ts @@ -5,8 +5,9 @@ describe('normalizeDateOnly', () => { expect(normalizeDateOnly('2026-07-02')).toBe('2026-07-02'); }); - it('converts legacy ISO timestamps to their UTC calendar date', () => { - expect(normalizeDateOnly('2026-07-01T16:00:00.000Z')).toBe('2026-07-01'); + it('converts legacy ISO timestamps to their China calendar date (UTC+8)', () => { + // 2026-07-01T16:00:00Z = 北京时间 2026-07-02 00:00 + expect(normalizeDateOnly('2026-07-01T16:00:00.000Z')).toBe('2026-07-02'); }); it('rejects unsupported date formats', () => { diff --git a/apps/server/src/database/date-normalization.ts b/apps/server/src/database/date-normalization.ts index 5717b619..6d9d65c1 100644 --- a/apps/server/src/database/date-normalization.ts +++ b/apps/server/src/database/date-normalization.ts @@ -9,7 +9,7 @@ export function normalizeDateOnly(value?: string | null): string | null | undefi const isoPrefix = ISO_DATE_PREFIX_PATTERN.exec(value)?.[1]; if (isoPrefix) { const date = new Date(value); - if (!Number.isNaN(date.getTime())) return dayjs(date).utc().format('YYYY-MM-DD'); + if (!Number.isNaN(date.getTime())) return dayjs(date).utcOffset(8).format('YYYY-MM-DD'); } throw new Error(`无效日期格式: ${value}`); diff --git a/apps/server/src/expenses/expense-operations.service.ts b/apps/server/src/expenses/expense-operations.service.ts index f51c1771..d64007a5 100644 --- a/apps/server/src/expenses/expense-operations.service.ts +++ b/apps/server/src/expenses/expense-operations.service.ts @@ -29,7 +29,7 @@ export class ExpenseOperationsService { private isValidDate(value: string) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; const date = new Date(`${value}T00:00:00Z`); - return !Number.isNaN(date.getTime()) && dayjs(date).utc().format('YYYY-MM-DD') === value; + return !Number.isNaN(date.getTime()) && dayjs(date).utcOffset(8).format('YYYY-MM-DD') === value; } async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) { diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index 53fc9c02..6910942f 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -342,7 +342,7 @@ export class ExpensesService { private isValidDate(value: string) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; const date = new Date(`${value}T00:00:00Z`); - return !Number.isNaN(date.getTime()) && dayjs(date).utc().format('YYYY-MM-DD') === value; + return !Number.isNaN(date.getTime()) && dayjs(date).utcOffset(8).format('YYYY-MM-DD') === value; } async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) { diff --git a/apps/server/src/occupancies/occupancy-operations.service.ts b/apps/server/src/occupancies/occupancy-operations.service.ts index f0c503e6..77896efe 100644 --- a/apps/server/src/occupancies/occupancy-operations.service.ts +++ b/apps/server/src/occupancies/occupancy-operations.service.ts @@ -159,7 +159,7 @@ export class OccupancyOperationsService { const transferDate = new Date(dto.transferDate); const nextDay = new Date(transferDate); nextDay.setDate(nextDay.getDate() + 1); - const defaultBillingStart = dayjs(nextDay).utc().format('YYYY-MM-DD'); + const defaultBillingStart = dayjs(nextDay).utcOffset(8).format('YYYY-MM-DD'); this.assertDateOrder( dto.transferDate, dto.newBillingStartDate || defaultBillingStart, diff --git a/apps/server/src/rbac/rbac-presets.ts b/apps/server/src/rbac/rbac-presets.ts index ea514f73..e4b0ac0e 100644 --- a/apps/server/src/rbac/rbac-presets.ts +++ b/apps/server/src/rbac/rbac-presets.ts @@ -148,7 +148,7 @@ import dayjs from '../common/dayjs'; export function getChinaDateParts(date = new Date()): { date: string; weekDay: number } { // Asia/Shanghai 无夏令时,固定 UTC+8 与 Intl en-CA 格式化等价 - const c = dayjs.utc(date).add(8, 'hour'); + const c = dayjs(date).utcOffset(8); const weekDay = c.day() === 0 ? 7 : c.day(); return { date: c.format('YYYY-MM-DD'), weekDay }; } diff --git a/apps/server/src/sync/schedule-sync.helpers.ts b/apps/server/src/sync/schedule-sync.helpers.ts index 559cbee8..e634c8ca 100644 --- a/apps/server/src/sync/schedule-sync.helpers.ts +++ b/apps/server/src/sync/schedule-sync.helpers.ts @@ -98,7 +98,7 @@ export function buildDailySchedulePlans( const toDate = new Date(`${syncTo}T00:00:00.000Z`); for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) { - const dateStr = dayjs(date).utc().format('YYYY-MM-DD'); + const dateStr = dayjs(date).utcOffset(8).format('YYYY-MM-DD'); const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay(); for (const schedule of schedules) { diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index e1bf143d..7f609ba7 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -71,8 +71,8 @@ export class SyncService { } const result = await this.attendanceImportService.importFromDingTalk({ - startDate: dayjs(startDate).utc().format('YYYY-MM-DD'), - endDate: dayjs(endDate).utc().format('YYYY-MM-DD'), + startDate: dayjs(startDate).utcOffset(8).format('YYYY-MM-DD'), + endDate: dayjs(endDate).utcOffset(8).format('YYYY-MM-DD'), userIds, autoMatch: true, }); From 9565a0f23cc746ab1429253fdde080894a0c9f98 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 18:37:55 +0800 Subject: [PATCH 37/43] =?UTF-8?q?feat(admin):=20=E6=B7=B1=E5=8C=96=20TanSt?= =?UTF-8?q?ack=20Query=20=E6=95=B0=E6=8D=AE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 queryKeys.ts 统一 QueryKey 工厂(覆盖全部模块,invalidate/refetch/prefetch 同源) - 新增 queryClient.ts 全局配置:retry 1、staleTime 30s、gcTime 5min、refetchOnWindowFocus false - 新增 useApiQuery:zod schema 校验 + 类型收敛 + select 转换,消除页面重复 validateResponse 样板 - useApiMutation 支持 onMutate/onSettled/context(乐观更新) - 示范迁移:Organizations(useApiQuery + 乐观更新归档/恢复)、Students(useApiQuery + queryKeys + 打开抽屉前 prefetch 档案聚合) - StudentProfileContent queryKey 统一为 queryKeys.archive.detail,prefetch 可命中缓存 --- apps/admin/src/api/queryClient.ts | 20 +++ apps/admin/src/api/queryKeys.ts | 145 ++++++++++++++++++ .../StudentProfileContent/index.tsx | 5 +- apps/admin/src/hooks/useApiMutation.ts | 46 ++++-- apps/admin/src/hooks/useApiQuery.ts | 31 ++++ apps/admin/src/main.tsx | 12 +- apps/admin/src/pages/Organizations/index.tsx | 51 ++++-- apps/admin/src/pages/Students/index.tsx | 63 +++++--- 8 files changed, 310 insertions(+), 63 deletions(-) create mode 100644 apps/admin/src/api/queryClient.ts create mode 100644 apps/admin/src/api/queryKeys.ts create mode 100644 apps/admin/src/hooks/useApiQuery.ts diff --git a/apps/admin/src/api/queryClient.ts b/apps/admin/src/api/queryClient.ts new file mode 100644 index 00000000..d0bb8074 --- /dev/null +++ b/apps/admin/src/api/queryClient.ts @@ -0,0 +1,20 @@ +import { QueryClient } from '@tanstack/react-query'; + +/** + * 全局 QueryClient:统一缓存/重试策略。 + * - retry 1:接口失败最多重试 1 次,避免瞬时错误直接白屏 + * - staleTime 30s:30 秒内重复请求走缓存 + * - gcTime 5min:不活跃缓存 5 分钟后回收 + * - refetchOnWindowFocus false:切回窗口不自动全量刷新, + * 保活页面由 useVisibleRefetch 按需刷新,避免重复请求 + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: 30_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/apps/admin/src/api/queryKeys.ts b/apps/admin/src/api/queryKeys.ts new file mode 100644 index 00000000..fabb82b6 --- /dev/null +++ b/apps/admin/src/api/queryKeys.ts @@ -0,0 +1,145 @@ +/** + * 统一 QueryKey 工厂。 + * + * 每个模块一个命名空间,key 由工厂函数生成: + * - 避免散落字符串字面量导致拼写不一致、缓存串扰 + * - invalidate / refetch / prefetch 与 useQuery 使用同一来源,不会对不上 + * + * 约定: + * - `all` 是该模块的「根 key」,用于整体失效(invalidateQueries 会匹配前缀) + * - 列表 key 按过滤条件展开;详情/子资源用固定段 + 参数 + */ +export const queryKeys = { + students: { + all: ['students'] as const, + list: (filters: { + search?: string; + status?: string; + archived?: boolean; + organizationId?: number; + classId?: number; + teacherId?: number; + }) => ['students', filters] as const, + organizations: (canView: boolean) => ['students', 'organizations', canView] as const, + filterLookups: () => ['students', 'filter-lookups'] as const, + }, + classes: { + all: ['classes'] as const, + list: (filters: { status?: string; type?: string; archived?: boolean }) => + ['classes', filters] as const, + detail: (id: number) => ['classes', 'detail', id] as const, + schedule: (id: number, dateRange: unknown) => ['classes', 'schedule', id, dateRange] as const, + attendanceSummary: (id: number, dateRange: unknown) => + ['classes', 'attendance-summary', id, dateRange] as const, + }, + classSchedules: { + all: ['class-schedules'] as const, + list: (params: { startDate?: string; endDate?: string; classroomIds?: unknown[] }) => + ['class-schedules', params] as const, + }, + classrooms: { + all: ['classrooms'] as const, + list: (archived: boolean) => ['classrooms', archived] as const, + }, + classroomRentals: { + all: ['classroom-rentals'] as const, + list: (month?: string) => ['classroom-rentals', month] as const, + schedule: (year: number, month: number) => + ['classroom-rentals', 'schedule', year, month] as const, + meta: () => ['classroom-rentals', 'meta'] as const, + }, + organizations: { + all: ['organizations'] as const, + list: () => ['organizations'] as const, + options: () => ['organizations', 'options'] as const, + }, + rbac: { + all: ['rbac'] as const, + users: (archived: boolean) => ['rbac', 'users', archived] as const, + allUsers: () => ['rbac', 'users', 'all'] as const, + teachers: (params: { page: number; pageSize: number; search?: string }) => + ['rbac', 'teachers', params] as const, + teacherWorkspace: () => ['rbac', 'teacher-workspace'] as const, + roles: () => ['rbac', 'roles'] as const, + permissionTree: () => ['rbac', 'roles', 'permission-tree'] as const, + permissionsTree: () => ['rbac', 'permissions', 'tree'] as const, + }, + expenses: { + all: ['expenses'] as const, + list: (archived: boolean) => ['expenses', archived ? 'archived' : 'active'] as const, + }, + expenseTypes: { + map: () => ['expense-types', 'map'] as const, + }, + expenseLookups: { + all: ['expense-lookups'] as const, + }, + bills: { + all: ['bills'] as const, + list: (filters: { status?: string; expenseType?: string }) => ['bills', filters] as const, + }, + wallets: { + all: ['wallets'] as const, + list: (params: { keyword?: string; debtOnly?: boolean; roomType?: string }) => + ['wallets', params] as const, + transactions: (studentId: number) => ['wallets', 'transactions', studentId] as const, + roomTypes: () => ['wallets', 'room-types'] as const, + }, + deposits: { + all: ['deposits'] as const, + list: () => ['deposits'] as const, + eligible: (roomType?: string) => ['deposits', 'eligible', roomType] as const, + }, + occupancies: { + all: ['occupancies'] as const, + list: (params: { viewMode?: string; dateRange?: unknown }) => ['occupancies', params] as const, + }, + rooms: { + all: ['rooms'] as const, + overview: (archived: boolean) => ['rooms', 'overview', archived] as const, + visual: (params: { historical: boolean; asOf?: unknown }) => + ['rooms', 'visual', params] as const, + }, + exams: { + all: ['exams'] as const, + detail: (id: number) => ['exams', 'detail', id] as const, + classes: () => ['exams', 'classes'] as const, + }, + operationLogs: { + all: ['operation-logs'] as const, + list: (params: { page: number; pageSize: number; module?: string; dateRange?: unknown }) => + ['operation-logs', params] as const, + }, + attendance: { + all: ['attendance'] as const, + workspace: () => ['attendance', 'workspace'] as const, + syncStatus: () => ['attendance', 'sync-status'] as const, + schedules: (classId: number, date: string) => ['attendance', 'schedules', classId, date] as const, + meta: { + periods: () => ['attendance', 'meta', 'periods'] as const, + classes: () => ['attendance', 'meta', 'classes'] as const, + alerts: () => ['attendance', 'meta', 'alerts'] as const, + }, + }, + attendanceDevices: { + all: ['attendance-devices'] as const, + }, + dashboard: { + all: ['dashboard'] as const, + summary: (period: unknown) => ['dashboard', period] as const, + }, + integration: { + config: () => ['integration', 'config'] as const, + }, + sync: { + jinshujuRules: () => ['sync', 'jinshuju', 'rules'] as const, + }, + archive: { + detail: (studentId: number) => ['archive', studentId] as const, + }, + ai: { + config: () => ['ai', 'config'] as const, + }, +} as const; + +export type QueryKeys = typeof queryKeys; diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 7ee66ce7..8f08aa5d 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -28,6 +28,7 @@ import { message } from '../../ui/app-message'; import { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; +import { queryKeys } from '../../api/queryKeys'; import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas'; import EditableCell from '../EditableCell'; import { usePermission } from '../../hooks/usePermission'; @@ -481,7 +482,7 @@ const StudentProfileContent: React.FC = ({ isError, refetch, } = useQuery({ - queryKey: ['archive', studentId], + queryKey: queryKeys.archive.detail(studentId), queryFn: async () => { return validateResponse( studentProfileAggregateSchema, @@ -492,7 +493,7 @@ const StudentProfileContent: React.FC = ({ const { data: organizations = [] } = useQuery< Array<{ id: number; name: string; isHost?: boolean }> >({ - queryKey: ['organizations', 'options'], + queryKey: queryKeys.organizations.options(), enabled: canLoadOrganizations, queryFn: async () => { try { diff --git a/apps/admin/src/hooks/useApiMutation.ts b/apps/admin/src/hooks/useApiMutation.ts index 3aefd861..141857e2 100644 --- a/apps/admin/src/hooks/useApiMutation.ts +++ b/apps/admin/src/hooks/useApiMutation.ts @@ -2,38 +2,62 @@ import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-quer import { message } from '../ui/app-message'; import { getErrorMessage } from '../utils/error'; -interface UseApiMutationOptions { +interface UseApiMutationOptions { /** 成功后自动失效的查询 key(触发列表/详情刷新) */ invalidate?: QueryKey[]; + /** 乐观更新:mutate 前同步改缓存,返回回滚上下文(失败时传给 onError) */ + onMutate?: (vars: TVars) => Promise | TContext | undefined; /** 成功后回调(例如关闭弹窗) */ - onSuccess?: (data: TData, vars: TVars) => void; - /** 失败回调;默认统一用 getErrorMessage 弹错误提示 */ - onError?: (error: unknown) => void; + onSuccess?: (data: TData, vars: TVars, context?: TContext) => void; + /** 失败回调;提供时由调用方负责(含乐观更新回滚),否则默认用 getErrorMessage 弹错误提示 */ + onError?: (error: unknown, vars: TVars, context?: TContext) => void; + /** 结束后回调(无论成败) */ + onSettled?: (data: TData | undefined, error: unknown, vars: TVars, context?: TContext) => void; } /** * useMutation 的轻量封装:统一错误提示 + 成功后 invalidateQueries, * 消除手写 `await api.xxx(); await fetchData();` 样板。 + * + * 乐观更新示例: + * ```ts + * const mutation = useApiMutation(fn, { + * onMutate: async (vars) => { + * await queryClient.cancelQueries({ queryKey }); + * const previous = queryClient.getQueryData(queryKey); + * queryClient.setQueryData(queryKey, updater); + * return previous; // 回滚上下文 + * }, + * onError: (_e, _v, previous) => queryClient.setQueryData(queryKey, previous), + * }); + * ``` */ -export function useApiMutation( +export function useApiMutation( mutationFn: (vars: TVars) => Promise, - options: UseApiMutationOptions = {}, + options: UseApiMutationOptions = {}, ) { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn, - onSuccess: (data, vars) => { + // 包装 onMutate:允许调用方返回 undefined(无回滚上下文), + // React Query 的 onMutate 类型要求返回 TContext + onMutate: async (vars) => { + const context = await options.onMutate?.(vars); + return context as TContext; + }, + onSuccess: (data, vars, context) => { for (const key of options.invalidate ?? []) { void queryClient.invalidateQueries({ queryKey: key }); } - options.onSuccess?.(data, vars); + options.onSuccess?.(data, vars, context); }, - onError: (error) => { + onError: (error, vars, context) => { if (options.onError) { - options.onError(error); + options.onError(error, vars, context); } else { message.error(getErrorMessage(error)); } }, + onSettled: options.onSettled, }); } diff --git a/apps/admin/src/hooks/useApiQuery.ts b/apps/admin/src/hooks/useApiQuery.ts new file mode 100644 index 00000000..afddd91b --- /dev/null +++ b/apps/admin/src/hooks/useApiQuery.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; +import type { QueryKey } from '@tanstack/react-query'; +import type { z } from 'zod'; +import { validateResponse } from '../utils/validate'; + +interface UseApiQueryOptions { + queryKey: QueryKey; + queryFn: () => Promise; + /** zod schema:响应校验失败会抛出带字段路径的错误,由统一错误处理展示 */ + schema: z.ZodType; + enabled?: boolean; + staleTime?: number; + /** 可选的数据转换(React Query select),例如列表原始行 → UI 模型 */ + select?: (data: T) => TSelected; +} + +/** + * useQuery 的类型安全封装:queryFn 返回 unknown, + * 由 zod schema 校验并收敛为 T,消除各页面重复的 + * `validateResponse(schema, await api.get(...))` 样板。 + */ +export function useApiQuery(options: UseApiQueryOptions) { + const { queryKey, queryFn, schema, enabled, staleTime, select } = options; + return useQuery({ + queryKey, + enabled, + staleTime, + queryFn: async () => validateResponse(schema, await queryFn()), + select, + }); +} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx index ba980178..3e2e6922 100644 --- a/apps/admin/src/main.tsx +++ b/apps/admin/src/main.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from './api/queryClient'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import App from './App'; import AppErrorBoundary from './components/AppErrorBoundary'; @@ -29,15 +30,6 @@ dayjs.extend(updateLocale); // 必须在所有插件加载后设置 locale dayjs.locale('zh-cn'); -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 1, - staleTime: 30_000, - }, - }, -}); - const rootElement = document.getElementById('root'); if (!rootElement) throw new Error('未找到 #root 挂载点'); diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index 5f678984..fb720455 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -1,16 +1,18 @@ // aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 import React, { useMemo, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import { App, Alert, Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons'; import api from '../../api'; +import { queryKeys } from '../../api/queryKeys'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { RefreshButton } from '../../components/RefreshButton'; import { usePermission } from '../../hooks/usePermission'; import { useApiMutation } from '../../hooks/useApiMutation'; -import { validateResponse } from '../../utils/validate'; +import { useApiQuery } from '../../hooks/useApiQuery'; +import { getErrorMessage } from '../../utils/error'; import { organizationsSchema } from '../../api/schemas'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; import { QueryEmpty, QueryErrorState } from '../../components/QueryState'; @@ -60,15 +62,13 @@ const OrganizationsPage: React.FC = () => { const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(); - const { data = [], isLoading, isFetching, isError, refetch } = useQuery({ - queryKey: ['organizations'], - queryFn: async () => - validateResponse( - organizationsSchema, - await api.get('/organizations', { - params: { includeArchived: true }, - }), - ), + const { data = [], isLoading, isFetching, isError, refetch } = useApiQuery({ + queryKey: queryKeys.organizations.list(), + schema: organizationsSchema, + queryFn: () => + api.get('/organizations', { + params: { includeArchived: true }, + }), }); const loading = isLoading || isFetching; @@ -77,23 +77,44 @@ const OrganizationsPage: React.FC = () => { editing ? api.put(`/organizations/${editing.id}`, values) : api.post('/organizations', values), - { invalidate: [['organizations']] }, + { invalidate: [queryKeys.organizations.all] }, ); const saveCellMutation = useApiMutation( async ({ record, field, value }: { record: OrganizationItem; field: string; value: unknown }) => api.put(`/organizations/${record.id}`, { [field]: value }), - { invalidate: [['organizations']] }, + { invalidate: [queryKeys.organizations.all] }, ); + const queryClient = useQueryClient(); const statusMutation = useApiMutation( async ({ id, status }: { id: number; status: 'active' | 'archived' }) => status === 'active' ? api.put(`/organizations/${id}`, { status: 'active' }) : api.delete(`/organizations/${id}`), - { invalidate: [['organizations']] }, + { + // 乐观更新:先同步改缓存,失败回滚,最终 invalidate 以服务器为准 + onMutate: async ({ id, status }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.organizations.all }); + const previous = queryClient.getQueryData( + queryKeys.organizations.list(), + ); + queryClient.setQueryData( + queryKeys.organizations.list(), + (old = []) => old.map((item) => (item.id === id ? { ...item, status } : item)), + ); + return previous; + }, + onError: (error, _vars, previous) => { + if (previous) queryClient.setQueryData(queryKeys.organizations.list(), previous); + message.error(getErrorMessage(error)); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.organizations.all }); + }, + }, ); const purgeMutation = useApiMutation( async (id: number) => api.delete(`/organizations/${id}/permanent`), - { invalidate: [['organizations']] }, + { invalidate: [queryKeys.organizations.all] }, ); const filteredData = useMemo(() => { diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 87f36a80..bd94cc58 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -13,11 +13,14 @@ import { NextStepHint } from '../../components/NextStepHint'; import { selectArchiveRecords } from '../archive-view'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; +import { useApiQuery } from '../../hooks/useApiQuery'; import { validateResponse } from '../../utils/validate'; +import { queryKeys } from '../../api/queryKeys'; import { organizationOptionsSchema, organizationsSchema, studentFilterLookupsSchema, + studentProfileAggregateSchema, studentsSchema, } from '../../api/schemas'; import { getErrorMessage } from '../../utils/error'; @@ -100,10 +103,24 @@ const StudentsPage: React.FC = () => { // 导入成功后的「下一步」引导提示 const [nextStepHint, setNextStepHint] = useState<'class' | null>(null); - const openDrawer = useCallback((studentId: number) => { - setDrawerStudentId(studentId); - setDrawerOpen(true); - }, []); + const queryClient = useQueryClient(); + const openDrawer = useCallback( + (studentId: number) => { + // prefetch 学生档案聚合数据:打开抽屉时通常已就绪,减少骨架屏等待 + void queryClient.prefetchQuery({ + queryKey: queryKeys.archive.detail(studentId), + queryFn: async () => + validateResponse( + studentProfileAggregateSchema, + await api.get(`/archive/${studentId}`), + ), + staleTime: 60_000, + }); + setDrawerStudentId(studentId); + setDrawerOpen(true); + }, + [queryClient], + ); const logCreateRef = React.useRef(hasPermission('log:create')); const sensitiveModalRef = React.useRef | null>(null); @@ -161,17 +178,17 @@ const StudentsPage: React.FC = () => { isFetching, isError, refetch, - } = useQuery({ - queryKey: [ - 'students', - searchName, - showArchived, - filterStatus, - effectiveFilterOrganizationId, - filterClassId, - filterTeacherId, - ], - queryFn: async () => { + } = useApiQuery>>({ + queryKey: queryKeys.students.list({ + search: searchName, + status: showArchived ? 'archived' : filterStatus, + archived: showArchived, + organizationId: effectiveFilterOrganizationId, + classId: filterClassId, + teacherId: filterTeacherId, + }), + schema: studentsSchema, + queryFn: () => { const params: Record = { name: searchName || undefined, includeArchived: showArchived ? 'true' : undefined, @@ -181,19 +198,15 @@ const StudentsPage: React.FC = () => { if (effectiveFilterOrganizationId) params.organizationId = effectiveFilterOrganizationId; if (filterClassId) params.classId = filterClassId; if (filterTeacherId) params.teacherId = filterTeacherId; - const res = (await api.get('/students', { params })) as Array>; - return selectArchiveRecords( - validateResponse>>(studentsSchema, res), - showArchived ? 'archived' : 'active', - ); + return api.get('/students', { params }); }, + select: (rows) => selectArchiveRecords(rows, showArchived ? 'archived' : 'active'), }); const loading = isLoading || isFetching; // RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据 - useVisibleRefetch(['students']); + useVisibleRefetch(queryKeys.students.all); - const queryClient = useQueryClient(); - const invalidateStudents: Array = [['students']]; + const invalidateStudents = [queryKeys.students.all]; const saveMutation = useApiMutation( async (values: Record) => editing ? api.put(`/students/${editing.id}`, values) : api.post('/students', values), @@ -246,7 +259,7 @@ const StudentsPage: React.FC = () => { const { data: organizations = [] } = useQuery< Array<{ id: number; name: string; isHost?: boolean }> >({ - queryKey: ['students', 'organizations', canViewOrganizations], + queryKey: queryKeys.students.organizations(canViewOrganizations), enabled: canLoadOrganizations, queryFn: async () => { try { @@ -268,7 +281,7 @@ const StudentsPage: React.FC = () => { }, }); const { data: lookups = { classes: [], teachers: [] } } = useQuery({ - queryKey: ['students', 'filter-lookups'], + queryKey: queryKeys.students.filterLookups(), enabled: canLoadOrganizations, queryFn: async () => { try { From 3bcad138a1544831869bb19533289df30cf7406f Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 9 Aug 2026 21:29:22 +0800 Subject: [PATCH 38/43] =?UTF-8?q?fix(ops):=20=E5=8A=A0=E5=9B=BA=E9=83=A8?= =?UTF-8?q?=E7=BD=B2/=E4=BB=A3=E7=90=86/=E8=BF=81=E7=A7=BB=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E7=94=9F=E4=BA=A7=E7=8E=AF=E5=A2=83=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=BB=98=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - serve-proxy:API_TARGET 生效、SPA 404 语义、流式静态文件、hop-by-hop/超时/断连/穿越防护 - migrate.sh:密码不进 argv/不泄漏 pm2、原子锁防重入、DML-only 事务说明、就绪诊断 - deploy.sh/CI:保护 .env、总是 npm ci(迁移依赖 devDeps)、健康检查、并发锁 - ecosystem/oxlint/.env.example:优雅停机、React 版本对齐、TRUST_PROXY 说明 Reviewed-by: OCR (open-codereview.ai) --- .env.example | 8 ++ .gitea/workflows/deploy.yml | 20 ++- deploy.sh | 92 +++++++++++-- ecosystem.config.cjs | 12 +- migrate.sh | 147 +++++++++++++++++++-- oxlint.config.ts | 5 +- serve-proxy.js | 249 ++++++++++++++++++++++++++++++++---- 7 files changed, 476 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index dc86107e..1fd26cc9 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,11 @@ AI_CONFIG_ENCRYPTION_KEY= # 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl(仅内网部署使用) # AI_ALLOW_PRIVATE_BASE_URL=true + +# ---- 安全 ---- +# 是否信任反向代理的 X-Forwarded-For / X-Real-IP(仅当部署在可信代理/Nginx 后时才设 true; +# 不设置时服务端只用 TCP socket 地址,防止伪造客户端 IP) +# TRUST_PROXY=true + +# 说明:第三方集成配置(钉钉/企微 appSecret)的静态加密复用 AI_CONFIG_ENCRYPTION_KEY, +# 存量明文可用 `cd apps/server && npm run encrypt:integration-secrets` 一次性加密。 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fd42e20c..aa8958ac 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -16,6 +16,10 @@ name: PM2 部署 on: workflow_dispatch: +concurrency: + group: deploy + cancel-in-progress: false + jobs: deploy: runs-on: ubuntu-latest @@ -37,7 +41,10 @@ jobs: - name: 配置 SSH run: | mkdir -p ~/.ssh - echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key + # 单引号 EOF 防止 key 中的 $ / 反引号被 shell 插值(YAML 会剥掉缩进,bash 实际收到列首 EOF) + cat > ~/.ssh/deploy_key <<'EOF' + ${{ secrets.SSH_PRIVATE_KEY }} + EOF chmod 600 ~/.ssh/deploy_key cat >> ~/.ssh/config <<'EOF' Host deploy-server @@ -59,17 +66,20 @@ jobs: --exclude='.turbo/' \ --exclude='.claude/' \ --exclude='.codegraph/' \ + --exclude='.env*' \ + --exclude='data.sql' \ + --exclude='uploads/' \ ./ deploy-server:${{ secrets.REMOTE_DIR }}/ - name: 安装依赖 → 迁移 → PM2 重载 run: | ssh deploy-server " + set -e cd ${{ secrets.REMOTE_DIR }} mkdir -p logs - if [ ! -d node_modules ]; then - echo '首次部署,安装生产依赖...' - npm ci --omit=dev - fi + echo '安装依赖...' + # 注意:migration:run 依赖 ts-node/tsconfig-paths(devDependencies),不能 --omit=dev + npm ci echo '执行数据库迁移...' npm run migration:run -w @gongxue/server echo 'PM2 重载...' diff --git a/deploy.sh b/deploy.sh index 45a0e90e..1da249c5 100755 --- a/deploy.sh +++ b/deploy.sh @@ -5,40 +5,112 @@ set -euo pipefail SSH_HOST="${1:-tencent}" + +# 校验 SSH_HOST:非空、不以 - 开头(防被 ssh/rsync 当作选项)、仅允许安全字符(防命令注入) +if [[ -z "${SSH_HOST}" ]]; then + echo "错误:SSH_HOST 不能为空" >&2 + exit 1 +fi +if [[ "${SSH_HOST}" == -* ]]; then + echo "错误:SSH_HOST 不能以 - 开头(会被 ssh/rsync 当作命令行选项)" >&2 + exit 1 +fi +if [[ ! "${SSH_HOST}" =~ ^[A-Za-z0-9._:@%+=,-]+$ ]]; then + echo "错误:SSH_HOST 含空白或 shell 元字符(仅允许字母、数字及 . _ : @ % + = , -)" >&2 + exit 1 +fi REMOTE_DIR="/opt/gongxue" -echo "=== 1/4 本地构建后端 ===" +# 调用锁:mkdir 原子创建,已存在即代表已有部署在跑,直接退出 +LOCK_DIR="/tmp/gongxue-deploy.lock" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "检测到 $LOCK_DIR,已有部署在运行。若确认没有其他部署,请手动删除该锁目录。" >&2 + exit 1 +fi +release_deploy_lock() { + rmdir "$LOCK_DIR" 2>/dev/null || true +} +trap release_deploy_lock EXIT + +echo "=== 1/5 本地构建后端 ===" npm run build -w @gongxue/server -echo "=== 2/4 本地构建前端 ===" +echo "=== 2/5 本地构建前端 ===" npm run build -w @gongxue/admin -echo "=== 3/4 同步到 ${SSH_HOST} ===" +echo "=== 3/5 同步到 ${SSH_HOST} ===" +# --delete 保留,但必须排除服务器端独有文件(.env、数据、上传目录),防止被清掉 rsync -avz --delete \ + -e "ssh -o BatchMode=yes -o ConnectTimeout=10" \ --exclude='node_modules' \ --exclude='.git' \ --exclude='*.db' \ --exclude='.DS_Store' \ --exclude='logs/' \ --exclude='.turbo/' \ + --exclude='.env*' \ + --exclude='data.sql' \ + --exclude='uploads/' \ ./ "${SSH_HOST}:${REMOTE_DIR}/" -echo "=== 4/4 安装依赖 → 迁移 → PM2 重载 ===" -ssh "${SSH_HOST}" " +echo "=== 4/5 安装依赖 → 迁移 → PM2 重载 ===" +ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" " + set -e cd ${REMOTE_DIR} mkdir -p logs - if [ ! -d node_modules ]; then - echo '首次部署,安装依赖...' - npm ci --omit=dev - fi + echo '安装依赖...' + # 注意:migration:run 依赖 ts-node/tsconfig-paths(devDependencies),不能 --omit=dev + npm ci echo '执行数据库迁移...' npm run migration:run -w @gongxue/server echo 'PM2 重载...' pm2 startOrReload ecosystem.config.cjs --update-env pm2 save + echo '=== PM2 状态 ===' pm2 status " +echo "=== 5/5 健康检查 ===" +# 后端未提供 /api/health 等专用健康端点,改用 pm2 jlist(JSON)按 name 查找 gongxue-backend: +# 判断 status === 'online' 且 unstable_restarts === 0。 +# 说明: +# - restart_time 是累计重启次数(正常滚动/长期运行也会累计),不适合做健康阈值,改用 unstable_restarts。 +# - online ≠ healthy:online 只代表 PM2 认为进程存活;若后续后端提供健康端点,应优先 curl 探测。 +# 带重试以覆盖 PM2 reload 的 listen_timeout(8s)窗口;应用不在线则 exit 1,部署失败。 +if ! ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" bash -s <<'REMOTE_HEALTH' +set -euo pipefail +HEALTHY=0 +for _i in $(seq 1 15); do + if pm2 jlist 2>/dev/null | node -e ' + let data = ""; + process.stdin.on("data", (c) => (data += c)); + process.stdin.on("end", () => { + const apps = JSON.parse(data || "[]"); + const app = apps.find((a) => a && a.name === "gongxue-backend"); + const env = (app || {}).pm2_env || {}; + if (env.status === "online" && Number(env.unstable_restarts || 0) === 0) { + process.exit(0); + } + process.exit(1); + }); + '; then + HEALTHY=1 + break + fi + sleep 2 +done +if [ "${HEALTHY}" -ne 1 ]; then + echo "错误:后端进程未就绪(gongxue-backend 非 online 或存在不稳定重启)" >&2 + pm2 status >&2 + exit 1 +fi +echo "健康检查通过:gongxue-backend 在线" +REMOTE_HEALTH +then + echo "错误:健康检查失败,部署中止" >&2 + exit 1 +fi + echo "" echo "部署完成!" -echo "访问: http://$(ssh "${SSH_HOST}" 'hostname -I 2>/dev/null | awk "{print \$1}" || curl -s ifconfig.me')" +echo "访问: http://$(ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" 'hostname -I 2>/dev/null | awk "{print \$1}" || curl -s ifconfig.me')" diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index a1bce54e..5bc2f8ed 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -4,7 +4,10 @@ // 生产环境配置写在项目根目录 .env,由 Nest ConfigModule 读取 // 前端构建产物 apps/admin/dist 交给 Nginx 托管,不再用 PM2 启动前端 +const path = require('path'); + const DEPLOY_DIR = process.env.DEPLOY_DIR || __dirname; +const LOG_DIR = path.join(DEPLOY_DIR, 'logs'); module.exports = { apps: [ @@ -21,9 +24,12 @@ module.exports = { max_memory_restart: '512M', max_restarts: 10, restart_delay: 5000, - // 日志 - error_file: 'logs/backend-error.log', - out_file: 'logs/backend-out.log', + // 优雅停机:给 Nest 时间排空请求/连接 + kill_timeout: 5000, + listen_timeout: 8000, + // 日志(绝对路径,与启动目录无关) + error_file: path.join(LOG_DIR, 'backend-error.log'), + out_file: path.join(LOG_DIR, 'backend-out.log'), log_date_format: 'YYYY-MM-DD HH:mm:ss', autorestart: true, watch: false, diff --git a/migrate.sh b/migrate.sh index 6d303ccc..bd01ce9f 100755 --- a/migrate.sh +++ b/migrate.sh @@ -5,33 +5,158 @@ # 2. ./migrate.sh /path/to/dump.sql.gz set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# 数据库名:旧库(导入源)与新库(应用目标)。 +# 注意:migrate-legacy.sql 内部仍固定写入 gongxue(未改 SQL 文件), +# 若修改 TARGET_DB 需同步调整该 SQL 文件,保持两者一致。 +LEGACY_DB="dorm_billing" +TARGET_DB="gongxue" + DUMP="${1:-}" if [ -z "$DUMP" ]; then echo "用法: ./migrate.sh /path/to/dump.sql.gz" echo "示例: ./migrate.sh ~/Downloads/dorm_billing_2026-07-06_01-30-02_mysql_data.sql.gz" exit 1 fi +# 相对路径转绝对路径:必须在 cd 之前转换,否则 cd 到脚本目录后相对路径会失效 +if [[ "$DUMP" != /* ]]; then + DUMP="$(pwd)/$DUMP" +fi +# 校验 dump 文件确实存在,避免 gunzip 报出难以理解的错误 +if [ ! -f "$DUMP" ]; then + echo "错误: dump 文件不存在: $DUMP" >&2 + exit 1 +fi -# 从 .env 读取密码 -MYSQL_PASS="${MYSQL_ROOT_PASSWORD:-gongxue_2024}" +# 先切到脚本目录:docker compose exec 与 pm2 startOrReload 都按调用者 cwd 解析, +# 不 cd 的话从其他目录调用会找不到 compose 文件 / ecosystem.config.cjs。 +cd "$SCRIPT_DIR" + +# 关键文件存在性预检,避免 set -euo pipefail 下出现难懂的错误 +if [ ! -f "$SCRIPT_DIR/migrate-legacy.sql" ]; then + echo "错误: 缺少 $SCRIPT_DIR/migrate-legacy.sql" >&2 + exit 1 +fi +if [ ! -f "$SCRIPT_DIR/ecosystem.config.cjs" ]; then + echo "错误: 缺少 $SCRIPT_DIR/ecosystem.config.cjs" >&2 + exit 1 +fi + +# .migrate.done 仅作为「已完成」提示:脚本成功结束时 touch; +# 开头若已存在则提示并退出(真正的防重入由下面的原子锁保证)。 +if [ -f "$SCRIPT_DIR/.migrate.done" ]; then + echo "检测到 $SCRIPT_DIR/.migrate.done,迁移已完成。如需重新迁移,请先删除该标记文件。" >&2 + # “已完成”是正常提示,不是错误,返回 0 + exit 0 +fi + +# 防重入:mkdir 原子锁。mkdir 创建目录是原子的,同一路径已存在即失败, +# 失败即代表已有实例正在运行。macOS 不自带 flock(1)(flock 是 Linux 专有命令), +# 所以不用 flock,改用目录锁;trap EXIT 保证成功/失败(含 ERR/INT/TERM 后的退出)都 rmdir 释放。 +if ! mkdir "$SCRIPT_DIR/.migrate.lock" 2>/dev/null; then + echo "检测到 $SCRIPT_DIR/.migrate.lock,已有迁移实例在运行。若确认无实例,请手动删除该锁目录。" >&2 + exit 1 +fi +release_lock() { + rmdir "$SCRIPT_DIR/.migrate.lock" 2>/dev/null || true +} +trap release_lock EXIT + +# 密码只通过 MYSQL_PWD 环境变量传递,绝不拼进命令行(避免出现在 ps aux / 日志) +if [ -z "${MYSQL_ROOT_PASSWORD:-}" ]; then + echo "错误: 请先设置环境变量 MYSQL_ROOT_PASSWORD(不要使用硬编码兜底密码)" >&2 + exit 1 +fi + +# .env 缺失时 sed 会非零退出,在 set -euo pipefail 下会直接终止脚本; +# 用 || true 兜底,缺失时回退到默认 ${LEGACY_DB}(现有逻辑保持不变)。 +# 用子 shell 限定 MYSQL_PWD 作用域: +# - 不拼进命令行(避免出现在 ps aux / cmdline) +# - 不全局 export(避免泄漏给 pm2 启动的后端进程) +run_mysql() { + ( + export MYSQL_PWD="$MYSQL_ROOT_PASSWORD" + docker compose exec -T -e MYSQL_PWD mysql mysql -u root "$@" + ) +} + +APP_STARTED=0 +# 失败时确保应用停止,避免留下半启动状态 +cleanup_on_error() { + if [ "$APP_STARTED" = "1" ]; then + echo "检测到失败,停止 gongxue-backend..." >&2 + pm2 stop gongxue-backend 2>/dev/null || true + fi +} +trap cleanup_on_error ERR INT TERM echo "=== 1/4 导入旧 dump ===" -gunzip -c "$DUMP" | docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" dorm_billing -echo "旧库导入完成: $(docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" -e 'SELECT COUNT(*) AS cnt FROM dorm_billing.students' -N)" +# 全新 MySQL 可能还没有 ${LEGACY_DB} schema,直接 mysql "${LEGACY_DB}" 会失败; +# run_mysql 是连 mysql 不带库名,先确保库存在再导入。 +run_mysql -e "CREATE DATABASE IF NOT EXISTS ${LEGACY_DB}; CREATE DATABASE IF NOT EXISTS ${TARGET_DB}" +gunzip -c "$DUMP" | run_mysql "$LEGACY_DB" +IMPORTED_COUNT="$(run_mysql -N -e "SELECT COUNT(*) FROM ${LEGACY_DB}.students" 2>/dev/null || true)" +if [ -z "${IMPORTED_COUNT}" ] || ! [[ "${IMPORTED_COUNT}" =~ ^[0-9]+$ ]]; then + echo "警告: 无法读取 ${LEGACY_DB}.students 计数(dump 可能不含该表或查询失败)" >&2 + IMPORTED_COUNT="未知" +fi +echo "旧库导入完成: ${IMPORTED_COUNT}" echo "=== 2/4 启动应用(创建新表结构) ===" -# 短暂启动让 TypeORM synchronize 创建表,然后停掉 -DB_SYNCHRONIZE=true pm2 start ecosystem.config.cjs --only gongxue-backend -sleep 8 -pm2 stop gongxue-backend +# 短暂启动让 TypeORM synchronize 创建表,然后停掉。 +# --update-env 确保 DB_SYNCHRONIZE=true 真正传给已在运行的 pm2 进程(startOrReload 默认不刷新环境变量)。 +APP_STARTED=1 +DB_SYNCHRONIZE=true pm2 startOrReload ecosystem.config.cjs --only gongxue-backend --update-env + +wait_for_tables() { + local tries=40 + # migrate-legacy.sql 内部仍固定写入 gongxue(未改 SQL 文件),默认值必须与 TARGET_DB 一致; + # 就绪检查只校验 ${TARGET_DB},不回退到 DB_DATABASE。 + echo "等待应用创建表结构(${TARGET_DB})..." + local i table_count err_file + err_file="$SCRIPT_DIR/.migrate.err" + : > "$err_file" + for i in $(seq 1 "$tries"); do + # 读入变量判断,避免 grep -q 触发 SIGPIPE;stderr 先收集,超时时输出便于诊断(如密码错误/容器未启动) + table_count="$(run_mysql -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${TARGET_DB}' AND table_name IN ('users','tenants','operation_logs','students','rooms')" 2>>"$err_file" || true)" + if [ "$table_count" = "5" ]; then + echo "表结构就绪(${TARGET_DB}: users/tenants/operation_logs/students/rooms)" + return 0 + fi + sleep 2 + done + echo "错误: 等待应用创建表结构超时(${TARGET_DB})" >&2 + if [ -s "$err_file" ]; then + echo "--- 期间 MySQL 错误(可能原因:密码错误/容器未启动/连接失败)---" >&2 + cat "$err_file" >&2 + fi + return 1 +} +wait_for_tables +pm2 stop gongxue-backend 2>/dev/null || true +APP_STARTED=0 echo "=== 3/4 执行数据迁移 ===" -docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" < migrate-legacy.sql +# 包进事务,中途失败时自动回滚(连接断开未提交即回滚)。 +# 注意:事务只对 DML 生效;migrate-legacy.sql 目前全部是 INSERT(无 DDL), +# 若将来加入 CREATE/ALTER/DROP 等 DDL,MySQL 会隐式提交,该回滚保证不再成立,需拆分 DDL/DML。 +{ + echo 'START TRANSACTION;' + cat "$SCRIPT_DIR/migrate-legacy.sql" + # 用 printf 保证 COMMIT 前有换行,避免粘到 SQL 最后一行 + printf '\nCOMMIT;\n' +} | run_mysql + +# 数据已提交:立即写「已完成」标记,之后重启失败也不应重跑导入(重跑会重复导入/重复 INSERT) +touch "$SCRIPT_DIR/.migrate.done" echo "=== 4/4 重启应用 ===" +# --update-env 确保 .env / 环境变量变更(如 DB_SYNCHRONIZE=false)对已在运行的进程生效 pm2 startOrReload ecosystem.config.cjs --update-env pm2 save + echo "" -echo "迁移完成!运行以下验证:" -echo " docker compose exec mysql mysql -u root -p${MYSQL_PASS} gongxue -e 'SELECT COUNT(*) FROM students'" +echo "迁移完成!运行以下验证(密码通过 MYSQL_PWD 传入,不会出现在进程列表):" +echo " export MYSQL_PWD=\"\$MYSQL_ROOT_PASSWORD\"; docker compose exec -T -e MYSQL_PWD mysql mysql -u root "${TARGET_DB}" -e 'SELECT COUNT(*) FROM students'" diff --git a/oxlint.config.ts b/oxlint.config.ts index 0241dbd3..f328af3d 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -3,12 +3,13 @@ import type { OxlintConfig } from 'oxlint'; const config: OxlintConfig = { plugins: ['typescript', 'react', 'import'], rules: { - 'typescript/no-explicit-any': 'off', + 'typescript/no-explicit-any': 'warn', 'typescript/no-non-null-assertion': 'warn', }, settings: { react: { - version: '19.0.0', + // 与仓库实际安装的 React 版本保持一致(apps/admin: ^19.2.5 → 19.2.7) + version: '19.2.7', }, }, }; diff --git a/serve-proxy.js b/serve-proxy.js index c9baf869..8a70f729 100644 --- a/serve-proxy.js +++ b/serve-proxy.js @@ -1,12 +1,28 @@ // 轻量静态文件 + API 代理服务器 // PM2 启动: node serve-proxy.js const http = require('http'); -const fs = require('fs'); +const https = require('https'); +const fs = require('fs/promises'); +const fsStream = require('fs'); const path = require('path'); -const PORT = process.env.FRONTEND_PORT || 5173; -const API_TARGET = process.env.API_TARGET || 'http://127.0.0.1:3000'; -const STATIC_DIR = path.join(__dirname, 'apps/admin/dist'); +// FRONTEND_PORT 非法(非整数)或 ≤0 时回退 5173,不抛错 +const parsedPort = Number(process.env.FRONTEND_PORT || 5173); +const PORT = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : 5173; +// API_TARGET 解析失败或协议不是 http/https 时回退默认值,不抛错(与 FRONTEND_PORT 回退风格一致) +let API_TARGET; +try { + const parsed = new URL(process.env.API_TARGET || 'http://127.0.0.1:3000'); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`API_TARGET 协议仅支持 http/https: ${parsed.protocol}`); + } + API_TARGET = parsed; +} catch { + API_TARGET = new URL('http://127.0.0.1:3000'); +} +const STATIC_DIR = process.env.FRONTEND_DIR + ? path.resolve(process.env.FRONTEND_DIR) + : path.join(__dirname, 'apps/admin/dist'); const MIME = { '.html': 'text/html; charset=utf-8', @@ -19,49 +35,230 @@ const MIME = { '.woff2': 'font/woff2', }; -function serveStatic(res, filePath) { +// RFC 7230 hop-by-hop headers — 不能透传给客户端 +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +function stripHopByHop(headers) { + const connection = headers.connection; + const out = { ...headers }; + for (const name of HOP_BY_HOP) delete out[name]; + if (typeof connection === 'string') { + for (const name of connection.split(',')) delete out[name.trim().toLowerCase()]; + } + return out; +} + +async function serveStatic(res, filePath) { + // 客户端断开/写失败兜底:所有分支(成功/404/SPA fallback/500)共用,避免 uncaughtException + res.on('error', () => {}); const ext = path.extname(filePath); const mime = MIME[ext] || 'application/octet-stream'; + let stat; try { - const content = fs.readFileSync(filePath); - res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800' }); - res.end(content); + stat = await fs.stat(filePath); } catch { - // SPA fallback: return index.html - const index = fs.readFileSync(path.join(STATIC_DIR, 'index.html')); - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(index); + // SPA fallback 只用于导航请求(无扩展名或 index.html);缺失的静态资源(.js/.css 等)必须 404 + const isNavigation = ext === '' || path.basename(filePath) === 'index.html'; + if (!isNavigation) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + // SPA fallback 的 index.html 单文件较小,保留 readFile 全量读取 + let content; + try { + content = await fs.readFile(path.join(STATIC_DIR, 'index.html')); + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + // SPA fallback 固定按 HTML 返回,且不能长缓存 + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-cache', + }); + res.end(content); + return; } + // 目录请求:stat 会成功但 createReadStream 会 EISDIR,必须在写头前拦截 + if (!stat.isFile()) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + // 防 symlink 穿越:stat/createReadStream 会跟随符号链接,realpath 解析后必须仍落在 STATIC_DIR 内 + try { + const [realRoot, realFile] = await Promise.all([ + fs.realpath(STATIC_DIR), + fs.realpath(filePath), + ]); + if (realFile !== realRoot && !realFile.startsWith(realRoot + path.sep)) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + // 静态文件流式化:先 stat 拿大小并写头(含 Content-Length),再 pipe 读流,避免全量缓冲 + res.writeHead(200, { + 'Content-Type': mime, + 'Content-Length': stat.size, + 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800', + }); + const stream = fsStream.createReadStream(filePath); + // 读流中途出错:头未发送时回 404/500,已发送则只能销毁连接 + stream.on('error', () => { + if (!res.headersSent) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + res.destroy(); + }); + // 客户端提前断开/连接关闭:销毁底层读流,避免 fd/socket 泄漏 + res.on('close', () => stream.destroy()); + stream.pipe(res); } const server = http.createServer((req, res) => { + // /api 精确匹配(不带尾斜杠):不代理,也不走 SPA fallback。 + // 若落到 SPA fallback 会返回 HTML 200,误导 API 客户端以为存在资源,直接 404 更明确。 + if (req.url.split('?')[0] === '/api') { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + // API 代理 if (req.url.startsWith('/api/')) { + const client = API_TARGET.protocol === 'https:' ? https : http; const opts = { - hostname: '127.0.0.1', - port: 3000, - path: req.url, + hostname: API_TARGET.hostname, + port: API_TARGET.port || (API_TARGET.protocol === 'https:' ? 443 : 80), + // 去掉 API_TARGET.pathname 的尾斜杠,避免拼出 /base//api/... 双斜杠;pathname 为 '/' 时得到空串 + path: (API_TARGET.pathname.replace(/\/+$/, '') || '') + req.url, method: req.method, - headers: { ...req.headers, host: '127.0.0.1:3000' }, + headers: { + ...stripHopByHop(req.headers), + host: API_TARGET.host, + // 代理是部署网络内的可信边界:补充客户端真实 IP/协议供后端审计与限流使用 + // 注意:不能赋 undefined(Node setHeader 会抛 ERR_HTTP_INVALID_HEADER_VALUE),为空时干脆不设 + ...(req.socket?.remoteAddress + ? { 'x-forwarded-for': String(req.socket.remoteAddress).split(',')[0].trim() } + : {}), + 'x-forwarded-proto': 'http', + }, }; - const proxy = http.request(opts, (proxyRes) => { - res.writeHead(proxyRes.statusCode, proxyRes.headers); - proxyRes.pipe(res); - }); + let proxy; + let timedOut = false; + try { + proxy = client.request(opts, (proxyRes) => { + // 响应头一到就清除 30s 超时:超时只在「等待响应头」阶段生效, + // 避免大文件下载中途被空闲超时截断 + proxy.setTimeout(0); + // 上游中途断开:避免未监听 error 事件导致进程崩溃。 + // res 可能已被 proxy.on('error') 分支销毁(双触发),先判断避免二次 destroy。 + proxyRes.on('error', () => { + if (res.destroyed) return; + res.destroy(); + }); + try { + // 上游响应头含非法字符(如 ERR_INVALID_CHAR)时 writeHead 会抛错,需兜底 + res.writeHead(proxyRes.statusCode, stripHopByHop(proxyRes.headers)); + proxyRes.pipe(res); + } catch { + // 异常时若头未发送回 502,已发送则销毁连接;同时释放上游 socket + proxyRes.unpipe(res); + proxy.destroy(); + proxyRes.destroy(); + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('API unavailable'); + } else { + res.destroy(); + } + } + }); + } catch { + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('API unavailable'); + } else { + res.destroy(); + } + return; + } + // 客户端断开/上游异常时 res 可能抛错,兜底监听防 uncaughtException + res.on('error', () => {}); proxy.on('error', () => { - res.writeHead(502); - res.end('API unavailable'); + // 超时回调已自行回 504 并 destroy,这里直接忽略,避免竞态下重复写响应 + if (timedOut) return; + // proxyRes.on('error') 分支可能已销毁 res(双触发),避免对已销毁响应二次写/destroy + if (res.destroyed) return; + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('API unavailable'); + } else { + res.destroy(); + } + }); + // 客户端断开:销毁上游请求,避免 socket 泄漏。 + // 注意:Node 中 req 在「请求体正常接收完毕」时也会触发 close(并非只有客户端断开), + // 无条件销毁会把仍在等待上游响应的正常请求误杀(上游 ECONNRESET → 502), + // 因此仅在连接确实已关闭(socket 已销毁或响应侧已销毁)时才销毁上游。 + req.on('close', () => { + if (req.socket?.destroyed || res.destroyed) proxy.destroy(); + }); + req.on('error', () => proxy.destroy()); + // 响应侧兜底:连接在响应写完前关闭(writableFinished=false)即客户端提前断开, + // 这是最可靠的断开信号(覆盖 req close/aborted 场景),此时销毁上游避免 socket 泄漏。 + res.on('close', () => { + if (!res.writableFinished) proxy.destroy(); + }); + // 上游 30s 无响应视为超时:先标记并 destroy(不带 error,避免触发 error 处理器二次写响应),再显式回 504 + proxy.setTimeout(30000, () => { + timedOut = true; + proxy.destroy(); + if (!res.headersSent) { + res.writeHead(504, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('API timeout'); + } else { + res.destroy(); + } }); req.pipe(proxy); return; } - // 静态文件 + // 静态文件(防目录穿越) const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0]; - const safePath = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, ''); - serveStatic(res, path.join(STATIC_DIR, safePath)); + const filePath = path.normalize(path.join(STATIC_DIR, urlPath)); + if (filePath !== STATIC_DIR && !filePath.startsWith(STATIC_DIR + path.sep)) { + res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Forbidden'); + return; + } + serveStatic(res, filePath).catch(() => { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); + } + res.end('Internal Server Error'); + }); }); server.listen(PORT, () => { - process.stdout.write(`Frontend proxy running on http://0.0.0.0:${PORT} → API: ${API_TARGET}\n`); + process.stdout.write(`Frontend proxy running on http://0.0.0.0:${PORT} → API: ${API_TARGET.href}\n`); }); From 99ea93140997f1504b0457a6ec79fa8cc78cdca8 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 9 Aug 2026 21:29:22 +0800 Subject: [PATCH 39/43] =?UTF-8?q?fix(security):=20=E8=AE=A4=E8=AF=81/?= =?UTF-8?q?=E8=B6=8A=E6=9D=83/=E6=B3=A8=E5=85=A5/=E4=B8=8A=E4=BC=A0/?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E5=85=A8=E9=93=BE=E8=B7=AF=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - JWT 生产必填、密码 8-72 字节、防枚举;全局 ValidationPipe - classes/dashboard/schedules/students/attendance/archive/exams 越权与 IDOR 修复 - LIKE 通配符转义(14 处);上传 10MB 上限 + MIME 白名单 + 附件 XSS - 集成配置 appSecret AES 加密 + 回填脚本;审计 best-effort;IP 来源防伪造 Reviewed-by: OCR (open-codereview.ai) --- apps/server/package.json | 1 + .../scripts/encrypt-integration-secrets.ts | 67 ++++++++ apps/server/src/ai-chat/ai-chat.controller.ts | 2 + .../src/ai-chat/ai-review.import-basic.ts | 8 +- apps/server/src/ai-chat/dto/ai-chat.dto.ts | 43 +++-- apps/server/src/archive/archive.controller.ts | 96 ++++++++++- apps/server/src/archive/archive.module.ts | 2 + .../archive/archive.purge.controller.spec.ts | 13 +- apps/server/src/archive/archive.service.ts | 30 ++++ .../dto/attendance-device.dto.ts | 2 + .../attendance-import.controller.ts | 7 +- .../src/attendance/attendance.service.ts | 20 ++- .../src/attendance/dto/attendance.dto.ts | 1 + apps/server/src/auth/auth.module.ts | 3 +- apps/server/src/auth/auth.service.ts | 8 +- apps/server/src/auth/dto/auth.dto.ts | 6 +- apps/server/src/auth/jwt-secret.spec.ts | 88 ++++++++++ apps/server/src/auth/jwt-secret.ts | 36 ++++ .../src/auth/strategies/jwt.strategy.ts | 3 +- apps/server/src/bills/bills-export.service.ts | 31 +++- .../src/classes/classes-queries.service.ts | 3 +- .../src/classes/classes.controller.spec.ts | 8 +- apps/server/src/classes/classes.controller.ts | 21 ++- apps/server/src/classes/classes.service.ts | 3 +- .../classroom-rentals.controller.ts | 11 +- .../src/classroom-rentals/dto/rental.dto.ts | 39 ++++- apps/server/src/common/like-escape.spec.ts | 22 +++ apps/server/src/common/like-escape.ts | 15 ++ apps/server/src/common/mime.spec.ts | 52 ++++++ apps/server/src/common/mime.ts | 43 +++++ apps/server/src/common/request-utils.ts | 21 ++- apps/server/src/common/with-audit-log.ts | 36 ++-- .../src/dashboard/dashboard.controller.ts | 20 +-- .../server/src/dashboard/dashboard.service.ts | 23 ++- .../dashboard/dto/dashboard-query.dto.spec.ts | 2 +- .../src/dashboard/dto/dashboard-query.dto.ts | 4 +- .../server/src/exams/exams.controller.spec.ts | 14 +- apps/server/src/exams/exams.controller.ts | 17 +- .../src/expenses/expenses.controller.ts | 7 +- apps/server/src/expenses/expenses.service.ts | 5 +- .../config/integration-config.service.spec.ts | 94 +++++++++++ .../config/integration-config.service.ts | 14 +- .../integration/config/secret-crypto.spec.ts | 58 +++++++ .../src/integration/config/secret-crypto.ts | 60 +++++++ apps/server/src/main.ts | 4 + .../src/occupancies/dto/occupancy.dto.spec.ts | 2 +- .../src/occupancies/dto/occupancy.dto.ts | 10 -- .../occupancies.boundaries.spec.ts | 1 + .../src/occupancies/occupancies.controller.ts | 2 +- apps/server/src/rbac/dto/rbac.dto.ts | 98 +++++++---- apps/server/src/rbac/rbac-user.service.ts | 12 +- apps/server/src/rbac/rbac.boundary.spec.ts | 157 +++++++++++++++++- apps/server/src/rooms/room-query.service.ts | 3 +- apps/server/src/rooms/rooms.controller.ts | 4 +- .../server/src/schedules/schedules.service.ts | 3 +- .../src/students/students.agent.service.ts | 3 +- .../src/students/students.controller.ts | 12 +- apps/server/src/students/students.service.ts | 46 +++-- .../src/sync/dto/schedule-sync.dto.spec.ts | 2 +- apps/server/src/sync/dto/schedule-sync.dto.ts | 3 +- apps/server/src/sync/sync.controller.ts | 3 +- 61 files changed, 1249 insertions(+), 175 deletions(-) create mode 100644 apps/server/scripts/encrypt-integration-secrets.ts create mode 100644 apps/server/src/auth/jwt-secret.spec.ts create mode 100644 apps/server/src/auth/jwt-secret.ts create mode 100644 apps/server/src/common/like-escape.spec.ts create mode 100644 apps/server/src/common/like-escape.ts create mode 100644 apps/server/src/common/mime.spec.ts create mode 100644 apps/server/src/common/mime.ts create mode 100644 apps/server/src/integration/config/secret-crypto.spec.ts create mode 100644 apps/server/src/integration/config/secret-crypto.ts diff --git a/apps/server/package.json b/apps/server/package.json index 896065cf..41cb0e17 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,6 +14,7 @@ "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "generate:student-import": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/generate-student-import-xlsx.ts", + "encrypt:integration-secrets": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/encrypt-integration-secrets.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint:fix": "npm run lint -- --fix", "typecheck": "tsc -p tsconfig.build.json --noEmit", diff --git a/apps/server/scripts/encrypt-integration-secrets.ts b/apps/server/scripts/encrypt-integration-secrets.ts new file mode 100644 index 00000000..c4fe2382 --- /dev/null +++ b/apps/server/scripts/encrypt-integration-secrets.ts @@ -0,0 +1,67 @@ +/// + +import datasource from '../datasource'; +import { encryptSecret, isEncryptedSecret } from '../src/integration/config/secret-crypto'; +import { IntegrationConfigDetail } from '../src/integration/entities/integration-config.entity'; + +/** integration_config_detail.content JSON 中与本次回填相关的结构。 */ +interface StoredConfigContent { + type?: unknown; + verify?: unknown; + config?: Record; +} + +async function main(): Promise { + // 主动检查加密密钥:缺失时 getEncryptionKey() 会静默使用开发回退密钥, + // 绝不能用回退密钥加密生产数据,因此未配置时直接报错退出。 + if (!process.env.AI_CONFIG_ENCRYPTION_KEY) { + throw new Error( + 'AI_CONFIG_ENCRYPTION_KEY 未设置:为避免使用开发回退密钥加密数据,请先配置 AI_CONFIG_ENCRYPTION_KEY 再运行回填脚本', + ); + } + + await datasource.initialize(); + console.log('已连接数据库,开始回填第三方集成配置 appSecret 加密...'); + + try { + const repo = datasource.getRepository(IntegrationConfigDetail); + const rows = await repo.find(); + let processed = 0; + + for (const row of rows) { + if (!row.content) continue; + + let parsed: StoredConfigContent; + try { + parsed = JSON.parse(row.content) as StoredConfigContent; + } catch { + continue; + } + + const config = parsed.config; + if (!config || typeof config !== 'object' || Array.isArray(config)) continue; + + const appSecret = config.appSecret; + if (typeof appSecret !== 'string' || !appSecret || isEncryptedSecret(appSecret)) continue; + + config.appSecret = encryptSecret(appSecret); + row.content = JSON.stringify(parsed); + await repo.save(row); + processed += 1; + } + + console.log(`回填完成:共处理 ${processed} 行(加密 appSecret)`); + } finally { + // 无论成功还是失败都关闭连接池,避免泄漏 + try { + await datasource.destroy(); + } catch { + // 销毁失败不影响主流程结果 + } + } +} + +main().catch((error) => { + console.error('回填失败:', error); + process.exitCode = 1; +}); diff --git a/apps/server/src/ai-chat/ai-chat.controller.ts b/apps/server/src/ai-chat/ai-chat.controller.ts index 49339f9f..4c1101b1 100644 --- a/apps/server/src/ai-chat/ai-chat.controller.ts +++ b/apps/server/src/ai-chat/ai-chat.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -108,6 +109,7 @@ export class AiChatController { @Req() req: AuthenticatedRequest, @UploadedFile() file: Express.Multer.File, ) { + if (!file) throw new BadRequestException('缺少上传文件'); const attachment = await this.attachmentService.upload(req.user.id, file); return { success: true, data: this.attachmentService.serialize(attachment) }; } diff --git a/apps/server/src/ai-chat/ai-review.import-basic.ts b/apps/server/src/ai-chat/ai-review.import-basic.ts index e01f894a..5199cbfe 100644 --- a/apps/server/src/ai-chat/ai-review.import-basic.ts +++ b/apps/server/src/ai-chat/ai-review.import-basic.ts @@ -7,6 +7,7 @@ import { RoomsService } from '../rooms/rooms.service'; import type { AiReviewSection } from './entities/ai-review.entity'; import { MAX_CAPACITY, normalizePhone } from './ai-review.shared'; import { resolveOrganizationId } from './ai-review.enrich'; +import { addDaysToDateOnly } from '../common/china-time'; export async function importStudents( section: AiReviewSection | undefined, @@ -172,10 +173,5 @@ export function normalizeFloor(raw: unknown): number | null { } export function nextDay(date: string): string { - const parsed = new Date(`${date}T00:00:00+08:00`); - parsed.setDate(parsed.getDate() + 1); - const year = parsed.getFullYear(); - const month = String(parsed.getMonth() + 1).padStart(2, '0'); - const day = String(parsed.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return addDaysToDateOnly(date, 1); } diff --git a/apps/server/src/ai-chat/dto/ai-chat.dto.ts b/apps/server/src/ai-chat/dto/ai-chat.dto.ts index 6b16241e..da4adab9 100644 --- a/apps/server/src/ai-chat/dto/ai-chat.dto.ts +++ b/apps/server/src/ai-chat/dto/ai-chat.dto.ts @@ -1,18 +1,5 @@ import { Type } from 'class-transformer'; -import { - ArrayMaxSize, - IsArray, - IsIn, - IsInt, - IsNotEmpty, - IsObject, - IsOptional, - IsString, - IsUUID, - Max, - MaxLength, - Min, -} from 'class-validator'; +import { ArrayMaxSize, IsArray, IsIn, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID, Max, MaxLength, Min, registerDecorator } from 'class-validator'; import { REASONING_EFFORT_LEVELS } from '../../ai-config/dto/ai-config.dto'; export class CreateConversationDto { @@ -89,11 +76,39 @@ export class EditMessageDto { reasoningEffort?: string | null; } +const MAX_FORM_VALUES = 200; + +/** 限制提交表单的字段数量,防止超大 body。 */ +export function MaxFormValues(limit = MAX_FORM_VALUES) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'maxFormValues', + target: object.constructor, + propertyName, + constraints: [limit], + validator: { + validate(value: unknown) { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.keys(value).length <= limit + ); + }, + defaultMessage(args) { + return `values 字段数量不能超过 ${args?.constraints?.[0] ?? limit} 个`; + }, + }, + }); + }; +} + export class SubmitFormDto { @IsUUID() clientRequestId: string; @IsObject() + @MaxFormValues() values: Record; @IsOptional() diff --git a/apps/server/src/archive/archive.controller.ts b/apps/server/src/archive/archive.controller.ts index f9afe1fa..118b9e71 100644 --- a/apps/server/src/archive/archive.controller.ts +++ b/apps/server/src/archive/archive.controller.ts @@ -12,8 +12,12 @@ import { UploadedFile, Res, ParseIntPipe, + BadRequestException, + NotFoundException, + UnauthorizedException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; +import { normalizeMimeType, isInlineSafeMimeType } from '../common/mime'; import type { Request as ExpressRequest, Response } from 'express'; import * as fs from 'fs'; import { ArchiveReportService } from './archive-report.service'; @@ -30,11 +34,17 @@ import { } from './dto/archive.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { withAuditLog } from '../common/with-audit-log'; +import { withAuditLog, logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import { StudentsService } from '../students/students.service'; interface AuthenticatedRequest extends ExpressRequest { - user?: { id: number; username?: string }; + user?: { + id: number; + username?: string; + permissions?: string[]; + isSuperAdmin?: boolean; + }; } @UseGuards(JwtAuthGuard) @@ -44,11 +54,42 @@ export class ArchiveController { private readonly archiveService: ArchiveService, private readonly logService: OperationLogsService, private readonly reportService: ArchiveReportService, + private readonly studentsService: StudentsService, ) {} + /** 超管或拥有 class:edit(等价于学生全范围)时视为可管理所有档案。 */ + private canManageAllArchive(req: AuthenticatedRequest): boolean { + const user = req.user; + if (!user) return false; + return user.isSuperAdmin === true || (user.permissions ?? []).includes('class:edit'); + } + + /** 校验当前用户能否访问指定学生的档案(防 IDOR)。 */ + private async assertStudentAccess(req: AuthenticatedRequest, studentId: number) { + const user = req.user; + if (!user) throw new UnauthorizedException(); + await this.studentsService.assertStudentAccess( + user.id, + studentId, + this.canManageAllArchive(req), + ); + } + + /** 按子记录 id 校验归属:先解析其 studentId,再做学生级范围校验。 */ + private async assertRecordAccess( + req: AuthenticatedRequest, + kind: 'enrollment' | 'examScore' | 'learningRecord' | 'attachment', + id: number, + ) { + const studentId = await this.archiveService.resolveRecordStudentId(kind, id); + if (studentId == null) throw new NotFoundException('记录不存在'); + await this.assertStudentAccess(req, studentId); + } + @Get(':studentId') @RequirePermission('student:view') async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '查看档案', targetId: studentId, targetType: 'archive', }), () => this.archiveService.getProfile(studentId)); @@ -61,6 +102,7 @@ export class ArchiveController { @Body() dto: UpsertProfileDto, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '更新档案信息', targetId: studentId, targetType: 'student_profile', detail: JSON.stringify(dto), }), () => this.archiveService.upsertProfile(studentId, dto)); @@ -73,6 +115,7 @@ export class ArchiveController { @Body() dto: CreateEnrollmentDto, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (result) => ({ module: '学生档案', action: '添加报名记录', targetId: result.id, targetType: 'student_enrollment', detail: `${dto.courseCategory} - ${dto.classType}`, }), () => this.archiveService.addEnrollment(studentId, dto)); @@ -85,6 +128,7 @@ export class ArchiveController { @Body() dto: UpdateEnrollmentDto, @Request() req: AuthenticatedRequest, ) { + await this.assertRecordAccess(req, 'enrollment', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '编辑报名记录', targetId: id, targetType: 'student_enrollment', detail: JSON.stringify(dto), }), () => this.archiveService.updateEnrollment(id, dto)); @@ -93,6 +137,7 @@ export class ArchiveController { @Delete('enrollments/:id') @RequirePermission('student:edit') async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'enrollment', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '归档报名记录', targetId: id, targetType: 'student_enrollment', }), () => this.archiveService.deleteEnrollment(id)); @@ -101,6 +146,7 @@ export class ArchiveController { @Delete('enrollments/:id/permanent') @RequirePermission('archive:purge') async purgeEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'enrollment', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '永久删除报名记录', targetId: id, targetType: 'student_enrollment', detail: '物理删除,不可恢复', }), () => this.archiveService.purgeEnrollment(id)); @@ -113,6 +159,7 @@ export class ArchiveController { @Body() dto: CreateExamScoreDto, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (result) => ({ module: '学生档案', action: '添加考试成绩', targetId: result.id, targetType: 'exam_score', detail: `${dto.examType} - ${dto.subject}: ${dto.score}`, }), () => this.archiveService.addExamScore(studentId, dto)); @@ -125,6 +172,7 @@ export class ArchiveController { @Body() dto: UpdateExamScoreDto, @Request() req: AuthenticatedRequest, ) { + await this.assertRecordAccess(req, 'examScore', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '编辑考试成绩', targetId: id, targetType: 'exam_score', detail: JSON.stringify(dto), }), () => this.archiveService.updateExamScore(id, dto)); @@ -133,6 +181,7 @@ export class ArchiveController { @Delete('exam-scores/:id') @RequirePermission('student:edit') async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'examScore', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '归档考试成绩', targetId: id, targetType: 'exam_score', }), () => this.archiveService.deleteExamScore(id)); @@ -141,6 +190,7 @@ export class ArchiveController { @Delete('exam-scores/:id/permanent') @RequirePermission('archive:purge') async purgeExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'examScore', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '永久删除考试成绩', targetId: id, targetType: 'exam_score', detail: '物理删除,不可恢复', }), () => this.archiveService.purgeExamScore(id)); @@ -153,6 +203,7 @@ export class ArchiveController { @Body() dto: CreateLearningRecordDto, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (result) => ({ module: '学生档案', action: '添加学习记录', targetId: result.id, targetType: 'learning_record', detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`, }), () => this.archiveService.addLearningRecord(studentId, dto)); @@ -165,6 +216,7 @@ export class ArchiveController { @Body() dto: UpdateLearningRecordDto, @Request() req: AuthenticatedRequest, ) { + await this.assertRecordAccess(req, 'learningRecord', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '编辑学习记录', targetId: id, targetType: 'learning_record', detail: JSON.stringify(dto), }), () => this.archiveService.updateLearningRecord(id, dto)); @@ -173,6 +225,7 @@ export class ArchiveController { @Delete('learning-records/:id') @RequirePermission('student:edit') async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'learningRecord', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '归档学习记录', targetId: id, targetType: 'learning_record', }), () => this.archiveService.deleteLearningRecord(id)); @@ -181,6 +234,7 @@ export class ArchiveController { @Delete('learning-records/:id/permanent') @RequirePermission('archive:purge') async purgeLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'learningRecord', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '永久删除学习记录', targetId: id, targetType: 'learning_record', detail: '物理删除,不可恢复', }), () => this.archiveService.purgeLearningRecord(id)); @@ -193,6 +247,7 @@ export class ArchiveController { @Body() dto: UpsertResultDto, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '更新录取结果', targetId: studentId, targetType: 'result_archive', detail: JSON.stringify(dto), }), () => this.archiveService.upsertResult(studentId, dto)); @@ -200,16 +255,19 @@ export class ArchiveController { @Post(':studentId/attachments') @RequirePermission('student:edit') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async uploadAttachment( @Param('studentId', ParseIntPipe) studentId: number, @UploadedFile() file: Express.Multer.File, @Body('category') category: string, @Request() req: AuthenticatedRequest, ) { + if (!file) throw new BadRequestException('缺少上传文件'); + const safeFile = { ...file, mimetype: normalizeMimeType(file.originalname, file.mimetype) }; + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, (result) => ({ - module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${file.originalname} (${category || 'other'})`, - }), () => this.archiveService.addAttachment(studentId, file, category || 'other')); + module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${safeFile.originalname} (${category || 'other'})`, + }), () => this.archiveService.addAttachment(studentId, safeFile, category || 'other')); } @Get(':studentId/attachments/:id') @@ -218,20 +276,42 @@ export class ArchiveController { @Param('studentId', ParseIntPipe) studentId: number, @Param('id', ParseIntPipe) id: number, @Res() res: Response, + @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile( studentId, id, ); - res.setHeader('Content-Type', mimeType); - res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`); + // 流式下载也记录审计:best-effort,日志失败绝不影响下载流 + await logAudit(this.logService, req, { + module: '学生档案', action: '下载附件', targetId: id, targetType: 'archive_attachment', + }); + const safeMimeType = normalizeMimeType(fileName, mimeType); + const disposition = isInlineSafeMimeType(safeMimeType) ? 'inline' : 'attachment'; + res.setHeader('Content-Type', safeMimeType); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader( + 'Content-Disposition', + `${disposition}; filename*=UTF-8''${encodeURIComponent(fileName)}`, + ); const stream = fs.createReadStream(fullPath); + stream.on('error', (err: NodeJS.ErrnoException) => { + if (res.headersSent) { + res.destroy(); + return; + } + const status = err?.code === 'ENOENT' ? 404 : 500; + res.status(status).json({ message: status === 404 ? '附件文件不存在' : '附件读取失败' }); + }); + res.on('close', () => stream.destroy()); stream.pipe(res); } @Delete('attachments/:id') @RequirePermission('student:edit') async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'attachment', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '归档附件', targetId: id, targetType: 'archive_attachment', }), () => this.archiveService.deleteAttachment(id)); @@ -240,6 +320,7 @@ export class ArchiveController { @Delete('attachments/:id/permanent') @RequirePermission('archive:purge') async purgeAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertRecordAccess(req, 'attachment', id); return withAuditLog(this.logService, req, (_result) => ({ module: '学生档案', action: '永久删除附件', targetId: id, targetType: 'archive_attachment', detail: '物理删除,不可恢复', }), () => this.archiveService.purgeAttachment(id)); @@ -251,6 +332,7 @@ export class ArchiveController { @Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest, ) { + await this.assertStudentAccess(req, studentId); return withAuditLog(this.logService, req, () => ({ module: 'archive', action: 'generate_report_html', targetId: studentId, targetType: 'student', }), async () => { diff --git a/apps/server/src/archive/archive.module.ts b/apps/server/src/archive/archive.module.ts index d755a89b..44655029 100644 --- a/apps/server/src/archive/archive.module.ts +++ b/apps/server/src/archive/archive.module.ts @@ -9,6 +9,7 @@ import { ResultArchive } from '../entities/result-archive.entity'; import { ArchiveAttachment } from '../entities/archive-attachment.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { NotificationsModule } from '../notifications/notifications.module'; +import { StudentsModule } from '../students/students.module'; import { ArchiveService } from './archive.service'; import { ArchiveReportService } from './archive-report.service'; import { ArchiveController } from './archive.controller'; @@ -26,6 +27,7 @@ import { ArchiveController } from './archive.controller'; AttendanceRecord, ]), NotificationsModule, + StudentsModule, ], controllers: [ArchiveController], providers: [ArchiveService, ArchiveReportService], diff --git a/apps/server/src/archive/archive.purge.controller.spec.ts b/apps/server/src/archive/archive.purge.controller.spec.ts index 81c64d7f..9e185405 100644 --- a/apps/server/src/archive/archive.purge.controller.spec.ts +++ b/apps/server/src/archive/archive.purge.controller.spec.ts @@ -21,15 +21,26 @@ describe('ArchiveController purge routes', () => { it('writes permanent delete audit logs for sub-records', async () => { const archiveService = { purgeEnrollment: jest.fn().mockResolvedValue({ message: '已永久删除报名记录(不可恢复)' }), + resolveRecordStudentId: jest.fn().mockResolvedValue(7), }; const log = jest.fn().mockResolvedValue(undefined); + const studentsService = { + assertStudentAccess: jest.fn().mockResolvedValue(undefined), + }; const controller = new ArchiveController( archiveService as never, { log } as never, {} as never, + studentsService as never, ); - const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + const req = { + user: { id: 1, username: 'admin', permissions: [], isSuperAdmin: false }, + ip: '127.0.0.1', + headers: {}, + }; await controller.purgeEnrollment(1, req); + expect(archiveService.resolveRecordStudentId).toHaveBeenCalledWith('enrollment', 1); + expect(studentsService.assertStudentAccess).toHaveBeenCalledWith(1, 7, false); expect(archiveService.purgeEnrollment).toHaveBeenCalledWith(1); expect(log).toHaveBeenCalledWith( expect.objectContaining({ module: '学生档案', action: '永久删除报名记录', targetId: 1 }), diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index 4cf083fc..11b5d8cc 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -57,6 +57,36 @@ export class ArchiveService { return fullPath; } + /** + * 解析某条档案子记录属于哪个学生(用于按学生范围做 IDOR 校验)。 + * 找不到返回 null。 + */ + async resolveRecordStudentId( + kind: 'enrollment' | 'examScore' | 'learningRecord' | 'attachment', + id: number, + ): Promise { + switch (kind) { + case 'enrollment': { + const row = await this.enrollmentRepo.findOne({ where: { id }, select: ['studentId'] }); + return row?.studentId ?? null; + } + case 'examScore': { + const row = await this.examScoreRepo.findOne({ where: { id }, select: ['studentId'] }); + return row?.studentId ?? null; + } + case 'learningRecord': { + const row = await this.learningRecordRepo.findOne({ where: { id }, select: ['studentId'] }); + return row?.studentId ?? null; + } + case 'attachment': { + const row = await this.attachmentRepo.findOne({ where: { id }, select: ['studentId'] }); + return row?.studentId ?? null; + } + default: + return null; + } + } + async getProfile(studentId: number) { const student = await this.studentRepo.findOne({ where: { id: studentId }, diff --git a/apps/server/src/attendance-devices/dto/attendance-device.dto.ts b/apps/server/src/attendance-devices/dto/attendance-device.dto.ts index e7731048..673aa6ed 100644 --- a/apps/server/src/attendance-devices/dto/attendance-device.dto.ts +++ b/apps/server/src/attendance-devices/dto/attendance-device.dto.ts @@ -26,6 +26,7 @@ export class CreateAttendanceDeviceDto { @IsOptional() @IsString() + @MaxLength(1000) notes?: string; } @@ -57,5 +58,6 @@ export class UpdateAttendanceDeviceDto { @IsOptional() @IsString() + @MaxLength(1000) notes?: string; } diff --git a/apps/server/src/attendance/attendance-import.controller.ts b/apps/server/src/attendance/attendance-import.controller.ts index d8f1397e..892ff17b 100644 --- a/apps/server/src/attendance/attendance-import.controller.ts +++ b/apps/server/src/attendance/attendance-import.controller.ts @@ -37,6 +37,8 @@ export class AttendanceImportController extends AttendanceControllerBase { @Body() dto: MatchDingRecordDto, @Request() req: { user: RequestUser }, ) { + const canManageAll = this.canManageAllAttendance(req); + await this.service.assertStudentAttendanceAccess(req.user.id, dto.studentId, canManageAll); const result = await this.service.matchDingRecord(id, dto); await logAudit(this.logService, req, { module: '考勤管理', action: '匹配考勤记录', targetId: id, targetType: 'dingAttendanceRaw', detail: `匹配到学生 ${dto.studentId}`, @@ -48,7 +50,10 @@ export class AttendanceImportController extends AttendanceControllerBase { @Post('ding-attendance-raw/auto-match') @RequirePermission('attendance:edit') - async autoMatch() { + async autoMatch(@Request() req: { user: RequestUser }) { + if (!this.canManageAllAttendance(req)) { + throw new ForbiddenException('仅管理员可执行全局自动匹配'); + } return this.service.autoMatchDingRecords(); } diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index d13b2ab7..2be7f208 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, DataSource } from 'typeorm'; import { @@ -251,6 +251,24 @@ export class AttendanceService { return this.queries.getClasses(...args); } + /** + * 校验某个学生是否在当前用户可访问的班级内,用于考勤匹配等敏感操作。 + * canManageAll 为 true 时跳过(管理员/超管)。 + */ + async assertStudentAttendanceAccess(userId: number, studentId: number, canManageAll: boolean) { + if (canManageAll) return; + const classIds = await this.getAccessibleClassIds(userId, false); + if (!classIds || classIds.length === 0) { + throw new ForbiddenException('无权操作该学生的考勤记录'); + } + const found = await this.classStudentRepo.findOne({ + where: { studentId, classId: In(classIds), status: 'active' }, + }); + if (!found) { + throw new ForbiddenException('无权操作该学生的考勤记录'); + } + } + async getDingRaw(...args: Parameters) { return this.queries.getDingRaw(...args); } diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts index 43b9469e..44bdf140 100644 --- a/apps/server/src/attendance/dto/attendance.dto.ts +++ b/apps/server/src/attendance/dto/attendance.dto.ts @@ -88,6 +88,7 @@ export class AttendanceRecordItem { export class BatchCreateAttendanceDto { @IsArray() @ArrayNotEmpty() + @ArrayMaxSize(500) @ValidateNested({ each: true }) @Type(() => AttendanceRecordItem) records: AttendanceRecordItem[]; diff --git a/apps/server/src/auth/auth.module.ts b/apps/server/src/auth/auth.module.ts index 420bfa83..1ace123e 100644 --- a/apps/server/src/auth/auth.module.ts +++ b/apps/server/src/auth/auth.module.ts @@ -7,6 +7,7 @@ import { User } from '../entities/user.entity'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './strategies/jwt.strategy'; +import { getJwtSecret } from './jwt-secret'; import { RbacModule } from '../rbac/rbac.module'; @Module({ @@ -17,7 +18,7 @@ import { RbacModule } from '../rbac/rbac.module'; imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ - secret: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'), + secret: getJwtSecret(config), signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '4h') }, }), }), diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts index d04ae1c1..8150db53 100644 --- a/apps/server/src/auth/auth.service.ts +++ b/apps/server/src/auth/auth.service.ts @@ -45,11 +45,11 @@ export class AuthService { if (!valid) { this.recordFailedAttempt(attemptKey); const att = loginAttempts.get(attemptKey); - const remaining = MAX_ATTEMPTS - (att?.count || 0); - if (remaining > 0) { - throw new UnauthorizedException(`用户名或密码错误,还剩 ${remaining} 次尝试机会`); + if (att?.lockedUntil && att.lockedUntil > new Date()) { + throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`); } - throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`); + // 与“用户不存在”返回同一文案,避免用户名枚举 + throw new UnauthorizedException('用户名或密码错误'); } // 登录成功,清除失败计数 diff --git a/apps/server/src/auth/dto/auth.dto.ts b/apps/server/src/auth/dto/auth.dto.ts index b32a24bf..3eaa22e4 100644 --- a/apps/server/src/auth/dto/auth.dto.ts +++ b/apps/server/src/auth/dto/auth.dto.ts @@ -1,10 +1,12 @@ -import { IsString, MinLength } from 'class-validator'; +import { IsString, MinLength, MaxLength } from 'class-validator'; export class LoginDto { @IsString() + @MaxLength(64) username: string; @IsString() - @MinLength(4) + @MinLength(8) + @MaxLength(72) password: string; } diff --git a/apps/server/src/auth/jwt-secret.spec.ts b/apps/server/src/auth/jwt-secret.spec.ts new file mode 100644 index 00000000..c325bd8a --- /dev/null +++ b/apps/server/src/auth/jwt-secret.spec.ts @@ -0,0 +1,88 @@ +import { ConfigService } from '@nestjs/config'; +import { getJwtSecret } from './jwt-secret'; + +const FALLBACK = 'dev-only-insecure-jwt-secret-do-not-use-in-production'; + +function makeConfig(secret?: string): ConfigService { + return { + get: jest.fn((key: string) => (key === 'JWT_SECRET' ? secret : undefined)), + } as unknown as ConfigService; +} + +const originalNodeEnv = process.env.NODE_ENV; +const originalSeedDev = process.env.SEED_DEV; + +afterEach(() => { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + if (originalSeedDev === undefined) { + delete process.env.SEED_DEV; + } else { + process.env.SEED_DEV = originalSeedDev; + } +}); + +describe('getJwtSecret', () => { + it('returns JWT_SECRET when configured, regardless of NODE_ENV', () => { + process.env.NODE_ENV = 'production'; + process.env.SEED_DEV = 'false'; + expect(getJwtSecret(makeConfig('configured-secret'))).toBe('configured-secret'); + }); + + it('returns JWT_SECRET when configured and NODE_ENV is unset', () => { + delete process.env.NODE_ENV; + delete process.env.SEED_DEV; + expect(getJwtSecret(makeConfig('configured-secret'))).toBe('configured-secret'); + }); + + it('throws when NODE_ENV is unset and SEED_DEV is unset', () => { + delete process.env.NODE_ENV; + delete process.env.SEED_DEV; + expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/); + }); + + it('throws when NODE_ENV=production', () => { + process.env.NODE_ENV = 'production'; + delete process.env.SEED_DEV; + expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/); + }); + + it('throws when NODE_ENV=production and SEED_DEV=true (SEED_DEV 只在 NODE_ENV 未设置时生效)', () => { + process.env.NODE_ENV = 'production'; + process.env.SEED_DEV = 'true'; + expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/); + }); + + it('throws when NODE_ENV=staging', () => { + process.env.NODE_ENV = 'staging'; + delete process.env.SEED_DEV; + expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/); + }); + + it('returns fallback when NODE_ENV=development', () => { + process.env.NODE_ENV = 'development'; + delete process.env.SEED_DEV; + expect(getJwtSecret(makeConfig())).toBe(FALLBACK); + }); + + it('returns fallback when NODE_ENV=test', () => { + process.env.NODE_ENV = 'test'; + delete process.env.SEED_DEV; + expect(getJwtSecret(makeConfig())).toBe(FALLBACK); + }); + + it('returns fallback when SEED_DEV=true even if NODE_ENV is unset', () => { + delete process.env.NODE_ENV; + process.env.SEED_DEV = 'true'; + expect(getJwtSecret(makeConfig())).toBe(FALLBACK); + }); + + it('returns fallback when SEED_DEV=true and NODE_ENV=development', () => { + process.env.NODE_ENV = 'development'; + process.env.SEED_DEV = 'true'; + expect(getJwtSecret(makeConfig())).toBe(FALLBACK); + }); +}); diff --git a/apps/server/src/auth/jwt-secret.ts b/apps/server/src/auth/jwt-secret.ts new file mode 100644 index 00000000..49e3795e --- /dev/null +++ b/apps/server/src/auth/jwt-secret.ts @@ -0,0 +1,36 @@ +import { ConfigService } from '@nestjs/config'; + +let warned = false; + +/** + * 获取 JWT 签名密钥。 + * - 已配置 JWT_SECRET 时直接返回该值(任何环境都正常返回)。 + * - 仅当 NODE_ENV 为 development/test,或 NODE_ENV 未设置且 npm run dev 注入的 + * SEED_DEV=true 时,允许回退到开发用默认密钥,并打印警告。 + * - NODE_ENV 为其他环境(含 production/staging)时,即使误设 SEED_DEV=true 也不回退; + * 未配置 JWT_SECRET 直接抛错,禁止使用默认/公开密钥。 + */ +export function getJwtSecret(config: ConfigService): string { + const secret = config.get('JWT_SECRET'); + if (secret) return secret; + + const env = process.env.NODE_ENV; + // SEED_DEV 是 npm run dev 的注入标记,只在 NODE_ENV 未设置(npm 脚本通常不设置)时生效; + // production 等环境即使误设 SEED_DEV=true 也绝不能走回退密钥。 + const isDev = + env === 'development' || + env === 'test' || + ((env === undefined || env === '') && process.env.SEED_DEV === 'true'); + if (!isDev) { + throw new Error( + 'JWT_SECRET 未配置:非 development/test 环境禁止使用默认密钥,请在 .env 中设置强随机 JWT_SECRET', + ); + } + if (!warned) { + warned = true; + console.warn( + '[AuthModule] 警告:JWT_SECRET 未配置,开发环境使用回退密钥。生产环境必须配置!', + ); + } + return 'dev-only-insecure-jwt-secret-do-not-use-in-production'; +} diff --git a/apps/server/src/auth/strategies/jwt.strategy.ts b/apps/server/src/auth/strategies/jwt.strategy.ts index 0e9ab64c..c1a004a1 100644 --- a/apps/server/src/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/auth/strategies/jwt.strategy.ts @@ -6,6 +6,7 @@ import { Request } from 'express'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../../entities/user.entity'; +import { getJwtSecret } from '../jwt-secret'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -27,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }, ]), ignoreExpiration: false, - secretOrKey: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'), + secretOrKey: getJwtSecret(config), }); } diff --git a/apps/server/src/bills/bills-export.service.ts b/apps/server/src/bills/bills-export.service.ts index e3a38ba8..18af5fc9 100644 --- a/apps/server/src/bills/bills-export.service.ts +++ b/apps/server/src/bills/bills-export.service.ts @@ -114,7 +114,23 @@ export class BillsExportService { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ); res.setHeader('Content-Disposition', `attachment; filename=bills_${Date.now()}.xlsx`); - await workbook.xlsx.write(res); + res.on('error', () => { + if (!res.headersSent) { + res.status(500).json({ message: '导出失败' }); + } else { + res.destroy(); + } + }); + try { + await workbook.xlsx.write(res); + } catch { + if (!res.headersSent) { + res.status(500).json({ message: '导出失败' }); + } else { + res.destroy(); + } + return; + } res.end(); } @@ -138,6 +154,19 @@ export class BillsExportService { const doc = new PDFDocument({ size: 'A4', margin: 50 }); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename=bill_${billId}.pdf`); + doc.on('error', () => { + if (!res.headersSent) { + res.status(500).json({ message: '导出失败' }); + } else { + res.destroy(); + } + }); + res.on('error', () => { + doc.destroy(); + }); + res.on('close', () => { + doc.destroy(); + }); doc.pipe(res); // 注册中文字体(优先使用系统字体,兼容 macOS 和 Linux) diff --git a/apps/server/src/classes/classes-queries.service.ts b/apps/server/src/classes/classes-queries.service.ts index 50c42caf..b6aa96d3 100644 --- a/apps/server/src/classes/classes-queries.service.ts +++ b/apps/server/src/classes/classes-queries.service.ts @@ -5,6 +5,7 @@ import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entitie import { Classroom } from '../entities/classroom.entity'; import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto'; +import { escapeLike } from '../common/like-escape'; import dayjs from '../common/dayjs'; interface AgentClassRow { @@ -53,7 +54,7 @@ export class ClassesQueriesService { } qb.where('class.isArchived = :isArchived', { isArchived: false }); if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds }); - if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` }); + if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${escapeLike(query.keyword)}%` }); if (query.status) qb.andWhere('class.status = :status', { status: query.status }); const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany(); return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) })); diff --git a/apps/server/src/classes/classes.controller.spec.ts b/apps/server/src/classes/classes.controller.spec.ts index 751dd4a2..388b6ead 100644 --- a/apps/server/src/classes/classes.controller.spec.ts +++ b/apps/server/src/classes/classes.controller.spec.ts @@ -127,9 +127,15 @@ describe('ClassesController purge route', () => { it('writes permanent delete audit logs', async () => { const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除班级(不可恢复)' }), + assertClassAccess: jest.fn().mockResolvedValue(undefined), }; const log = jest.fn().mockResolvedValue(undefined); - const controller = new ClassesController(service as never, { log } as never, {} as never, {} as never); + const controller = new ClassesController( + service as never, + { log } as never, + {} as never, + { can: jest.fn().mockReturnValue(true) } as never, + ); const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; await controller.purge('1', req); expect(service.purge).toHaveBeenCalledWith(1); diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index 29dfaf7e..e27ac311 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -126,27 +126,35 @@ export class ClassesController { /** 批量导入学生到班级(通过钉钉用户ID) */ @Post(':id/students/import') @RequirePermission('class:edit') - async batchImportStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: BatchImportStudentsDto) { + async batchImportStudents( + @Param('id', ParseIntPipe) id: number, + @Body() dto: BatchImportStudentsDto, + @Request() req: AuthenticatedRequest, + ) { + await this.assertReadAccess(req, +id); return this.service.batchImportStudents(+id, dto.users); } /** 归档班级 */ @Put(':id/archive') @RequirePermission('class:edit') - async archive(@Param('id', ParseIntPipe) id: number) { + async archive(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); return this.service.archive(+id); } /** 取消归档 */ @Put(':id/restore') @RequirePermission('class:edit') - async restore(@Param('id', ParseIntPipe) id: number) { + async restore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); return this.service.restore(+id); } @Put(':id') @RequirePermission('class:edit') async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto), @@ -157,6 +165,7 @@ export class ClassesController { @Delete(':id') @RequirePermission('class:delete') async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class', @@ -167,6 +176,7 @@ export class ClassesController { @Delete(':id/permanent') @RequirePermission('class:purge') async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复', @@ -227,6 +237,7 @@ export class ClassesController { @Post(':id/students') @RequirePermission('class:edit') async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); const result = await this.service.addStudents(+id, dto.studentIds); await logAudit(this.logService, req, { module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`, @@ -254,6 +265,7 @@ export class ClassesController { @Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest, ) { + await this.assertReadAccess(req, +id); const result = await this.service.removeStudent(+id, +studentId); await logAudit(this.logService, req, { module: '班级管理', action: '移除学生', targetId: +id, targetType: 'class', detail: `移除学生${studentId}`, @@ -271,6 +283,7 @@ export class ClassesController { @Post(':id/teachers') @RequirePermission('class:edit') async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: AuthenticatedRequest) { + await this.assertReadAccess(req, +id); const result = await this.service.addTeacher(+id, dto); await logAudit(this.logService, req, { module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`, @@ -295,6 +308,7 @@ export class ClassesController { @Param('assignmentId', ParseIntPipe) assignmentId: number, @Request() req: AuthenticatedRequest, ) { + await this.assertReadAccess(req, +id); const result = await this.service.removeTeacherAssignment(+id, +assignmentId); await logAudit(this.logService, req, { module: '班级管理', action: '移除教师角色', targetId: +id, targetType: 'class', detail: `移除教师分配${assignmentId}`, @@ -309,6 +323,7 @@ export class ClassesController { @Param('userId', ParseIntPipe) userId: number, @Request() req: AuthenticatedRequest, ) { + await this.assertReadAccess(req, +id); const result = await this.service.removeTeacher(+id, +userId); await logAudit(this.logService, req, { module: '班级管理', action: '移除教师', targetId: +id, targetType: 'class', detail: `移除教师${userId}`, diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index 05084384..e6b5bd26 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -9,6 +9,7 @@ import { DataSource, Repository, In, Like } from 'typeorm'; +import { escapeLike } from '../common/like-escape'; import { Class, ClassStudent, @@ -102,7 +103,7 @@ export class ClassesService { const where: Record = {}; if (query.status) where.status = query.status; if (query.classType) where.classType = query.classType; - if (query.keyword) where.name = Like(`%${query.keyword}%`); + if (query.keyword) where.name = Like(`%${escapeLike(query.keyword)}%`); // Default: hide archived, unless explicitly requested where.isArchived = query.isArchived ?? false; diff --git a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts index 4c8b6ba0..a5b1cf7c 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts @@ -185,7 +185,7 @@ export class ClassroomRentalsController { @UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest, ) { - if (!file) throw new BadRequestException('请上传合同文件'); + if (!file) throw new BadRequestException('缺少上传文件'); const result = await this.service.attachContract(+id, file); await logAudit(this.logService, req, { module: '教室租赁', action: '上传合同', targetId: +id, targetType: 'classroom-rental', detail: file.originalname, @@ -203,6 +203,15 @@ export class ClassroomRentalsController { `attachment; filename="${encodeURIComponent(originalName)}"`, ); const stream = fs.createReadStream(fullPath); + stream.on('error', (err: NodeJS.ErrnoException) => { + if (res.headersSent) { + res.destroy(); + return; + } + const status = err?.code === 'ENOENT' ? 404 : 500; + res.status(status).json({ message: status === 404 ? '合同文件不存在' : '合同文件读取失败' }); + }); + res.on('close', () => stream.destroy()); stream.pipe(res); } diff --git a/apps/server/src/classroom-rentals/dto/rental.dto.ts b/apps/server/src/classroom-rentals/dto/rental.dto.ts index 61f08c63..635d7664 100644 --- a/apps/server/src/classroom-rentals/dto/rental.dto.ts +++ b/apps/server/src/classroom-rentals/dto/rental.dto.ts @@ -1,4 +1,37 @@ -import { IsOptional, IsString, IsInt, IsNumber, IsISO8601, Matches, Min } from 'class-validator'; +import { + IsISO8601, + IsInt, + IsNumber, + IsOptional, + IsString, + Matches, + MaxLength, + Min, + Validate, + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +/** + * 跨字段校验:endDate 不能早于 startDate。 + * 仅在两个字段都存在时校验(UpdateRentalDto 允许只更新其中一个)。 + */ +@ValidatorConstraint({ name: 'IsDateRangeValid', async: false }) +class IsDateRangeValidConstraint implements ValidatorConstraintInterface { + validate(_value: string, args: ValidationArguments): boolean { + const { startDate, endDate } = args.object as { + startDate?: string; + endDate?: string; + }; + if (startDate === undefined || endDate === undefined) return true; + return endDate >= startDate; + } + + defaultMessage(): string { + return '结束日期不能早于开始日期'; + } +} export class CreateRentalDto { @IsInt() @@ -17,6 +50,7 @@ export class CreateRentalDto { @Matches(/^\d{4}-\d{2}-\d{2}$/) @IsISO8601({ strict: true }) + @Validate(IsDateRangeValidConstraint) endDate: string; @IsOptional() @@ -31,6 +65,7 @@ export class CreateRentalDto { @IsOptional() @IsString() + @MaxLength(1000) notes?: string; } @@ -55,6 +90,7 @@ export class UpdateRentalDto { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) @IsISO8601({ strict: true }) + @Validate(IsDateRangeValidConstraint) endDate?: string; @IsOptional() @@ -69,5 +105,6 @@ export class UpdateRentalDto { @IsOptional() @IsString() + @MaxLength(1000) notes?: string; } diff --git a/apps/server/src/common/like-escape.spec.ts b/apps/server/src/common/like-escape.spec.ts new file mode 100644 index 00000000..3f7beaf4 --- /dev/null +++ b/apps/server/src/common/like-escape.spec.ts @@ -0,0 +1,22 @@ +import { escapeLike } from './like-escape'; + +describe('escapeLike', () => { + it.each([ + ['%', '\\%'], + ['_', '\\_'], + ['\\', '\\\\'], + ['50%_off', '50\\%\\_off'], + ['a\\b%c_d', 'a\\\\b\\%c\\_d'], + ])('escapes LIKE wildcard "%s" -> "%s"', (input, expected) => { + expect(escapeLike(input)).toBe(expected); + }); + + it('keeps ordinary strings unchanged', () => { + expect(escapeLike('张三')).toBe('张三'); + expect(escapeLike('hello world 123')).toBe('hello world 123'); + }); + + it('keeps an empty string empty', () => { + expect(escapeLike('')).toBe(''); + }); +}); diff --git a/apps/server/src/common/like-escape.ts b/apps/server/src/common/like-escape.ts new file mode 100644 index 00000000..df0dd39f --- /dev/null +++ b/apps/server/src/common/like-escape.ts @@ -0,0 +1,15 @@ +/** + * 转义 SQL LIKE 模式中的通配符,防止用户输入里的 `%` / `_` / `\` 扩大匹配范围。 + * + * 反斜杠必须最先转义:否则用户输入的 `\` 会被数据库当作转义符, + * 把后面的通配符(或普通字符)变成字面量,改变匹配语义。 + * 转义后配合 MySQL 默认的 `\` 转义符,`%` / `_` / `\` 都会被当作字面量匹配。 + */ +export function escapeLike(input: string): string { + return input + .replace(/\\/g, '\\\\') + .replace(/%/g, '\\%') + .replace(/_/g, '\\_'); +} + +export default escapeLike; diff --git a/apps/server/src/common/mime.spec.ts b/apps/server/src/common/mime.spec.ts new file mode 100644 index 00000000..44e36992 --- /dev/null +++ b/apps/server/src/common/mime.spec.ts @@ -0,0 +1,52 @@ +import { normalizeMimeType, isInlineSafeMimeType } from './mime'; + +describe('normalizeMimeType', () => { + it.each([ + ['report.pdf', 'application/pdf'], + ['photo.png', 'image/png'], + ['pic.jpeg', 'image/jpeg'], + ['pic.jpg', 'image/jpeg'], + ['anim.webp', 'image/webp'], + ['doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ['sheet.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + ['data.csv', 'text/csv'], + ['notes.txt', 'text/plain'], + ])('maps %s to %s by extension', (filename, expected) => { + expect(normalizeMimeType(filename)).toBe(expected); + }); + + it('is case-insensitive for the extension', () => { + expect(normalizeMimeType('REPORT.PDF')).toBe('application/pdf'); + expect(normalizeMimeType('Photo.JPG')).toBe('image/jpeg'); + }); + + it('ignores a spoofed client mimeType', () => { + expect(normalizeMimeType('evil.png', 'text/html')).toBe('image/png'); + expect(normalizeMimeType('evil.html', 'image/png')).toBe('application/octet-stream'); + }); + + it('falls back to octet-stream for unknown/unsafe extensions', () => { + expect(normalizeMimeType('script.svg')).toBe('application/octet-stream'); + expect(normalizeMimeType('payload.html')).toBe('application/octet-stream'); + expect(normalizeMimeType('virus.exe')).toBe('application/octet-stream'); + expect(normalizeMimeType('noextension')).toBe('application/octet-stream'); + expect(normalizeMimeType('')).toBe('application/octet-stream'); + }); +}); + +describe('isInlineSafeMimeType', () => { + it('allows images and pdf', () => { + expect(isInlineSafeMimeType('application/pdf')).toBe(true); + expect(isInlineSafeMimeType('image/png')).toBe(true); + expect(isInlineSafeMimeType('image/jpeg')).toBe(true); + expect(isInlineSafeMimeType('image/webp')).toBe(true); + }); + + it('rejects document, archive and scriptable types', () => { + expect(isInlineSafeMimeType('application/octet-stream')).toBe(false); + expect(isInlineSafeMimeType('text/html')).toBe(false); + expect(isInlineSafeMimeType('image/svg+xml')).toBe(false); + expect(isInlineSafeMimeType('text/plain')).toBe(false); + expect(isInlineSafeMimeType('')).toBe(false); + }); +}); diff --git a/apps/server/src/common/mime.ts b/apps/server/src/common/mime.ts new file mode 100644 index 00000000..26ec8333 --- /dev/null +++ b/apps/server/src/common/mime.ts @@ -0,0 +1,43 @@ +/** + * 上传附件 MIME 类型归一化。 + * + * 存储/返回的 mimeType 不应直接信任客户端请求头(可被伪造), + * 统一按文件扩展名白名单归一化,白名单之外的按 octet-stream 处理。 + */ + +const MIME_BY_EXTENSION: Record = { + pdf: 'application/pdf', + png: 'image/png', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + webp: 'image/webp', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + csv: 'text/csv', + txt: 'text/plain', +}; + +const INLINE_SAFE_MIME_TYPES = new Set([ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/webp', +]); + +/** + * 按文件扩展名白名单归一化 MIME 类型。 + * 客户端提供的 mimeType 仅作参考,实际以扩展名为准。 + */ +export function normalizeMimeType(filename: string, _clientMime?: string): string { + const dotIndex = filename.lastIndexOf('.'); + const ext = dotIndex >= 0 ? filename.slice(dotIndex + 1).toLowerCase() : ''; + return MIME_BY_EXTENSION[ext] ?? 'application/octet-stream'; +} + +/** + * 该 MIME 类型是否允许 `Content-Disposition: inline` 内联展示 + * (仅限图片与 PDF 这类无脚本执行能力的类型,SVG 等一律视为不安全)。 + */ +export function isInlineSafeMimeType(mime: string): boolean { + return INLINE_SAFE_MIME_TYPES.has(mime); +} diff --git a/apps/server/src/common/request-utils.ts b/apps/server/src/common/request-utils.ts index 5ba570c4..813089c6 100644 --- a/apps/server/src/common/request-utils.ts +++ b/apps/server/src/common/request-utils.ts @@ -2,18 +2,25 @@ export interface RequestInfoSource { headers?: Record; connection?: { remoteAddress?: string }; + socket?: { remoteAddress?: string }; } /** - * 从请求对象中提取客户端 IP 和 UserAgent + * 从请求对象中提取客户端 IP 和 UserAgent。 + * 默认不信任 X-Forwarded-For / X-Real-IP(客户端可伪造), + * 仅当显式设置 TRUST_PROXY=1/true(部署在可信反向代理后)时才使用代理头。 */ export function extractRequestInfo(req: RequestInfoSource): { ipAddress: string; userAgent: string } { - const forwarded = - req.headers?.['x-forwarded-for'] || - req.headers?.['x-real-ip'] || - req.connection?.remoteAddress || - ''; - const ipAddress = String(forwarded).split(',')[0].trim() || 'unknown'; + const trustProxy = process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true'; + let ipAddress: string; + if (trustProxy) { + const forwarded = + req.headers?.['x-forwarded-for'] || req.headers?.['x-real-ip'] || ''; + ipAddress = String(forwarded).split(',')[0].trim(); + } else { + ipAddress = req.socket?.remoteAddress || req.connection?.remoteAddress || ''; + } + if (!ipAddress) ipAddress = 'unknown'; const userAgent = String(req.headers?.['user-agent'] || '').substring(0, 500); return { ipAddress, userAgent }; } diff --git a/apps/server/src/common/with-audit-log.ts b/apps/server/src/common/with-audit-log.ts index 8362675d..b9a09901 100644 --- a/apps/server/src/common/with-audit-log.ts +++ b/apps/server/src/common/with-audit-log.ts @@ -33,13 +33,17 @@ export async function withAuditLog( ): Promise { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await operation(); - await logService.log({ - userId: req.user?.id, - username: req.user?.username, - ipAddress, - userAgent, - ...buildEntry(result), - }); + try { + await logService.log({ + userId: req.user?.id, + username: req.user?.username, + ipAddress, + userAgent, + ...buildEntry(result), + }); + } catch { + // 审计日志为 best-effort:写入失败绝不能把已成功的业务操作变成失败 + } return result; } @@ -53,11 +57,15 @@ export async function logAudit( entry: AuditLogEntry, ): Promise { const { ipAddress, userAgent } = extractRequestInfo(req); - await logService.log({ - userId: req.user?.id, - username: req.user?.username, - ipAddress, - userAgent, - ...entry, - }); + try { + await logService.log({ + userId: req.user?.id, + username: req.user?.username, + ipAddress, + userAgent, + ...entry, + }); + } catch { + // 审计日志为 best-effort:日志失败绝不向外抛 + } } diff --git a/apps/server/src/dashboard/dashboard.controller.ts b/apps/server/src/dashboard/dashboard.controller.ts index 0ede9645..ffb004da 100644 --- a/apps/server/src/dashboard/dashboard.controller.ts +++ b/apps/server/src/dashboard/dashboard.controller.ts @@ -44,18 +44,18 @@ export class DashboardController { } @Get('gantt') - getGanttData(@Query() query: DashboardGanttQueryDto) { - return this.service.getGanttData(query); + async getGanttData(@Query() query: DashboardGanttQueryDto, @Request() req: { user: RequestUser }) { + return this.service.getGanttData(query, await this.getAccessibleClassIds(req)); } @Get('expense-stats') - getExpenseStats(@Query() query: DashboardPeriodQueryDto) { - return this.service.getExpenseStats(query.periodStart, query.periodEnd); + async getExpenseStats(@Query() query: DashboardPeriodQueryDto, @Request() req: { user: RequestUser }) { + return this.service.getExpenseStats(query.periodStart, query.periodEnd, await this.getAccessibleClassIds(req)); } @Get('room-ranking') - getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto) { - return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd); + async getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto, @Request() req: { user: RequestUser }) { + return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd, await this.getAccessibleClassIds(req)); } @Get('class-attendance-ranking') @@ -64,12 +64,12 @@ export class DashboardController { } @Get('classroom-occupancy') - getClassroomOccupancy() { - return this.service.getClassroomOccupancy(); + async getClassroomOccupancy(@Request() req: { user: RequestUser }) { + return this.service.getClassroomOccupancy(await this.getAccessibleClassIds(req)); } @Get('classroom-utilization') - async getClassroomUtilization() { - return this.service.getClassroomUtilizationStats(); + async getClassroomUtilization(@Request() req: { user: RequestUser }) { + return this.service.getClassroomUtilizationStats(await this.getAccessibleClassIds(req)); } } diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index 974dd972..e3fbbd12 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -257,15 +257,22 @@ export class DashboardService { return this.queries.getIncomeTrend(this.billRepo, currentMonth); } - async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { + async getGanttData( + query?: { periodStart?: string; periodEnd?: string; building?: string }, + accessibleClassIds?: number[], + ) { + // 非管理员(教师/学服等班级范围用户)看不到全局宿舍财务数据 + if (accessibleClassIds) return []; return this.queries.getGanttData(this.occRepo, (a, b) => this.assertPeriodRange(a, b), query); } - async getExpenseStats(periodStart?: string, periodEnd?: string) { + async getExpenseStats(periodStart?: string, periodEnd?: string, accessibleClassIds?: number[]) { + if (accessibleClassIds) return []; return this.queries.getExpenseStats(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd); } - async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { + async getRoomExpenseRanking(periodStart?: string, periodEnd?: string, accessibleClassIds?: number[]) { + if (accessibleClassIds) return []; return this.queries.getRoomExpenseRanking(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd); } @@ -281,7 +288,9 @@ export class DashboardService { return dayjs.utc(`${ym}-01`).add(1, 'month').format('YYYY-MM-DD'); } - async getClassroomOccupancy() { + async getClassroomOccupancy(accessibleClassIds?: number[]) { + // 非管理员看不到全局教室使用情况 + if (accessibleClassIds) return []; const classrooms = await this.classroomRepo.find({ where: { status: 'available' as const }, order: { building: 'ASC', name: 'ASC' }, @@ -328,7 +337,11 @@ export class DashboardService { return dayjs(date).utcOffset(8).format('YYYY-MM-DD'); } - async getClassroomUtilizationStats() { + async getClassroomUtilizationStats(accessibleClassIds?: number[]) { + // 非管理员看不到全局教室利用率 + if (accessibleClassIds) { + return { totalClassrooms: 0, inUseCount: 0, utilizationRate: '0', scheduleCount: 0, rentalCount: 0 }; + } const totalClassrooms = await this.classroomRepo.count({ where: { status: 'available' as const }, }); diff --git a/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts b/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts index ac7e6ea7..4b621565 100644 --- a/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts +++ b/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts @@ -4,7 +4,7 @@ import { validate } from 'class-validator'; import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dashboard-query.dto'; describe('dashboard query boundaries', () => { - it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])( + it.each(['2026-07-13T00:00:00Z', '2026-7-13'])( 'rejects invalid or non-date-only value %s', async (periodStart) => { const dto = plainToInstance(DashboardPeriodQueryDto, { periodStart }); diff --git a/apps/server/src/dashboard/dto/dashboard-query.dto.ts b/apps/server/src/dashboard/dto/dashboard-query.dto.ts index 5f2aafdb..2c3760b1 100644 --- a/apps/server/src/dashboard/dto/dashboard-query.dto.ts +++ b/apps/server/src/dashboard/dto/dashboard-query.dto.ts @@ -1,14 +1,12 @@ -import { IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; +import { IsOptional, IsString, Matches, MaxLength } from 'class-validator'; export class DashboardPeriodQueryDto { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) periodStart?: string; @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) periodEnd?: string; } diff --git a/apps/server/src/exams/exams.controller.spec.ts b/apps/server/src/exams/exams.controller.spec.ts index 724195a8..1e237bf9 100644 --- a/apps/server/src/exams/exams.controller.spec.ts +++ b/apps/server/src/exams/exams.controller.spec.ts @@ -18,13 +18,23 @@ describe('ExamsController batch archive and restore', () => { }; it.each(['batchArchive', 'batchRestore'] as const)( - '%s uses the existing exam permission', + '%s requires the exam write permission', (method) => { const handler = ExamsController.prototype[method] as (...args: never[]) => unknown; - expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual(['exam:view']); + expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual(['exam:edit']); }, ); + it.each([ + ['create', 'exam:create'], + ['archive', 'exam:edit'], + ['restore', 'exam:edit'], + ['updateScore', 'exam:edit'], + ] as const)('%s requires %s', (method, permission) => { + const handler = ExamsController.prototype[method] as (...args: never[]) => unknown; + expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual([permission]); + }); + it('class-level validation rejects invalid and non-whitelisted batch bodies', async () => { const pipes = Reflect.getMetadata(PIPES_METADATA, ExamsController) as ValidationPipe[]; expect(pipes).toHaveLength(1); diff --git a/apps/server/src/exams/exams.controller.ts b/apps/server/src/exams/exams.controller.ts index cdcf0848..5e2dd1f8 100644 --- a/apps/server/src/exams/exams.controller.ts +++ b/apps/server/src/exams/exams.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -49,7 +50,7 @@ export class ExamsController { } @Put('batch-archive') - @RequirePermission('exam:view') + @RequirePermission('exam:edit') async batchArchive(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchArchive( dto.ids, @@ -63,7 +64,7 @@ export class ExamsController { } @Put('batch-restore') - @RequirePermission('exam:view') + @RequirePermission('exam:edit') async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestore( dto.ids, @@ -83,7 +84,7 @@ export class ExamsController { } @Post() - @RequirePermission('exam:view') + @RequirePermission('exam:create') async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto, req.user.id, this.canManageAll(req)); await logAudit(this.logService, req, { @@ -93,7 +94,7 @@ export class ExamsController { } @Put(':id/archive') - @RequirePermission('exam:view') + @RequirePermission('exam:edit') async archive( @Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest, @@ -106,7 +107,7 @@ export class ExamsController { } @Put(':id/restore') - @RequirePermission('exam:view') + @RequirePermission('exam:edit') async restore( @Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest, @@ -146,13 +147,17 @@ export class ExamsController { } @Put(':examId/scores/:scoreId') - @RequirePermission('exam:view') + @RequirePermission('exam:edit') async updateScore( @Param('examId', ParseIntPipe) examId: number, @Param('scoreId', ParseIntPipe) scoreId: number, @Body() dto: UpdateExamScoreValueDto, @Request() req: AuthenticatedRequest, ) { + // 防止空 PUT 静默清空成绩:score 必须显式提供(清空请传 null) + if (dto.score === undefined) { + throw new BadRequestException('score 字段必填(清空请传 null)'); + } const result = await this.service.updateScore( examId, scoreId, diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 7886b8c3..4b3cdee8 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -16,6 +16,7 @@ import { ParseIntPipe, UsePipes, ValidationPipe, + BadRequestException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import dayjs from '../common/dayjs'; @@ -351,8 +352,9 @@ export class ExpensesController { @Post('utility/import') @RequirePermission('expense:create') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { + if (!file) throw new BadRequestException('缺少上传文件'); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; @@ -416,8 +418,9 @@ export class ExpensesController { @Post('personal/import') @RequirePermission('expense:create') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { + if (!file) throw new BadRequestException('缺少上传文件'); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index 6910942f..6685d5d0 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -14,6 +14,7 @@ import { import { BillsService } from '../bills/bills.service'; import dayjs from '../common/dayjs'; import { ExpenseOperationsService } from './expense-operations.service'; +import { escapeLike } from '../common/like-escape'; /** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */ type RawScalarRow = Record; @@ -147,7 +148,7 @@ export class ExpensesService { } roomQb.where('e.status = :status', { status: 'active' }); if (query?.keyword) { - roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` }); + roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${escapeLike(query.keyword)}%` }); } if (query?.periodStart) { roomQb.andWhere('e.periodStart >= :periodStart', { periodStart: query.periodStart }); @@ -178,7 +179,7 @@ export class ExpensesService { if (query?.keyword) { personalQb.andWhere( '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', - { keyword: `%${query.keyword}%` }, + { keyword: `%${escapeLike(query.keyword)}%` }, ); } if (query?.periodStart) { diff --git a/apps/server/src/integration/config/integration-config.service.spec.ts b/apps/server/src/integration/config/integration-config.service.spec.ts index cb559cbc..8710c58e 100644 --- a/apps/server/src/integration/config/integration-config.service.spec.ts +++ b/apps/server/src/integration/config/integration-config.service.spec.ts @@ -1,4 +1,5 @@ import { IntegrationConfigService } from './integration-config.service'; +import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto'; describe('IntegrationConfigService.testConnection', () => { const originalFetch = global.fetch; @@ -97,3 +98,96 @@ describe('IntegrationConfigService security boundaries', () => { ).resolves.toBe(false); }); }); + +describe('IntegrationConfigService appSecret encryption', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('encrypts AppSecret before persisting content', async () => { + const configRepo = { + findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }), + create: jest.fn(), + save: jest.fn(), + }; + const detailRepo = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((data) => data), + save: jest.fn().mockResolvedValue(undefined), + }; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ accessToken: 'token' }), + }) as never; + + const service = new IntegrationConfigService(configRepo as never, detailRepo as never); + + await service.saveConfig({ + type: 'DINGTALK', + config: { corpId: 'corp', agentId: 'agent', appSecret: 'plain-secret' }, + } as never); + + expect(detailRepo.create).toHaveBeenCalled(); + const saved = detailRepo.create.mock.calls[0][0]; + const parsed = JSON.parse(saved.content) as { + config: { appSecret: string }; + }; + expect(parsed.config.appSecret).not.toBe('plain-secret'); + expect(isEncryptedSecret(parsed.config.appSecret)).toBe(true); + expect(decryptSecret(parsed.config.appSecret)).toBe('plain-secret'); + }); + + it('decrypts an encrypted stored AppSecret when reading raw config', async () => { + const configRepo = { + findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }), + }; + const detailRepo = { + findOne: jest.fn().mockResolvedValue({ + configId: 1, + type: 'DINGTALK_SYNC', + content: JSON.stringify({ + config: { + corpId: 'corp', + agentId: 'agent', + appSecret: encryptSecret('saved-secret'), + }, + }), + }), + }; + const service = new IntegrationConfigService(configRepo as never, detailRepo as never); + + await expect(service.getRawConfig('DINGTALK')).resolves.toEqual({ + corpId: 'corp', + agentId: 'agent', + appSecret: 'saved-secret', + }); + }); + + it('keeps masking AppSecret even when the stored value is encrypted', async () => { + const content = JSON.stringify({ + config: { + corpId: 'corp', + agentId: 'agent', + appSecret: encryptSecret('top-secret'), + }, + }); + const configRepo = { + findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }), + }; + const detailRepo = { + find: jest.fn().mockResolvedValue([{ type: 'DINGTALK_SYNC', enable: true, content }]), + }; + const service = new IntegrationConfigService(configRepo as never, detailRepo as never); + + await expect(service.getThirdConfig()).resolves.toEqual([ + { + type: 'DINGTALK', + verify: true, + config: { corpId: 'corp', agentId: 'agent' }, + }, + ]); + }); +}); diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts index ba12be88..941d5248 100644 --- a/apps/server/src/integration/config/integration-config.service.ts +++ b/apps/server/src/integration/config/integration-config.service.ts @@ -11,6 +11,7 @@ import { IntegrationType, SaveIntegrationConfigDto, } from './dto/config.dto'; +import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto'; /** 第三方配置在 content JSON 中的存储结构。 */ interface StoredConfigShape { @@ -34,7 +35,13 @@ export class IntegrationConfigService { private parseStoredConfig(content: string): Record { const parsed = JSON.parse(content) as StoredConfigShape; const rawCfg = parsed.config || parsed; - return rawCfg && typeof rawCfg === 'object' ? (rawCfg as Record) : {}; + if (!rawCfg || typeof rawCfg !== 'object') return {}; + const cfg = rawCfg as Record; + // 读取路径统一在这里解密 appSecret(信封 → 明文),兼容存量明文。 + if (typeof cfg.appSecret === 'string' && isEncryptedSecret(cfg.appSecret)) { + return { ...cfg, appSecret: decryptSecret(cfg.appSecret) }; + } + return cfg; } /** 获取或创建主配置(全局单例) */ @@ -109,6 +116,11 @@ export class IntegrationConfigService { const token = await this.getTokenForTest(request.type, finalConfig); const verified = !!token; + // 写库前对 appSecret 做静态加密(其他字段不动);已是信封则跳过避免二次加密。 + if (finalConfig.appSecret && !isEncryptedSecret(stringify(finalConfig.appSecret))) { + finalConfig.appSecret = encryptSecret(stringify(finalConfig.appSecret)); + } + const content = JSON.stringify({ type: request.type, verify: verified, diff --git a/apps/server/src/integration/config/secret-crypto.spec.ts b/apps/server/src/integration/config/secret-crypto.spec.ts new file mode 100644 index 00000000..3127a01b --- /dev/null +++ b/apps/server/src/integration/config/secret-crypto.spec.ts @@ -0,0 +1,58 @@ +import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto'; + +describe('secret-crypto', () => { + describe('encryptSecret / decryptSecret', () => { + it('round-trips an appSecret through the JSON envelope', () => { + const secret = 'ding-app-secret-abc123'; + const envelope = encryptSecret(secret); + + expect(JSON.parse(envelope)).toEqual( + expect.objectContaining({ + v: 1, + c: expect.any(String), + i: expect.any(String), + t: expect.any(String), + }), + ); + expect(envelope).not.toContain(secret); + expect(decryptSecret(envelope)).toBe(secret); + }); + + it('produces a different envelope per call (random IV)', () => { + expect(encryptSecret('same-secret')).not.toBe(encryptSecret('same-secret')); + }); + + it('returns plaintext unchanged when input is not an envelope (legacy compatibility)', () => { + expect(decryptSecret('legacy-plain-secret')).toBe('legacy-plain-secret'); + expect(decryptSecret('')).toBe(''); + }); + }); + + describe('isEncryptedSecret', () => { + it('recognizes generated envelopes', () => { + expect(isEncryptedSecret(encryptSecret('anything'))).toBe(true); + }); + + it('rejects plaintext, malformed JSON and partial envelopes', () => { + expect(isEncryptedSecret('plain-secret')).toBe(false); + expect(isEncryptedSecret('')).toBe(false); + expect(isEncryptedSecret('not-json')).toBe(false); + expect(isEncryptedSecret('{"v":1}')).toBe(false); + expect(isEncryptedSecret('{"v":2,"c":"a","i":"b","t":"c"}')).toBe(false); + expect(isEncryptedSecret('{"v":1,"c":"","i":"b","t":"c"}')).toBe(false); + expect(isEncryptedSecret('{"v":1,"c":123,"i":"b","t":"c"}')).toBe(false); + }); + }); + + describe('invalid envelope tolerance', () => { + it('returns the input unchanged for structurally incomplete envelopes', () => { + expect(decryptSecret('{"v":1}')).toBe('{"v":1}'); + expect(decryptSecret('{"v":1,"c":"a","i":"b"}')).toBe('{"v":1,"c":"a","i":"b"}'); + }); + + it('returns the input unchanged when decryption fails', () => { + const garbage = '{"v":1,"c":"!!","i":"!!","t":"!!"}'; + expect(decryptSecret(garbage)).toBe(garbage); + }); + }); +}); diff --git a/apps/server/src/integration/config/secret-crypto.ts b/apps/server/src/integration/config/secret-crypto.ts new file mode 100644 index 00000000..80d66e95 --- /dev/null +++ b/apps/server/src/integration/config/secret-crypto.ts @@ -0,0 +1,60 @@ +import { decrypt, encrypt } from '../../ai-config/ai-config.helpers'; + +/** + * appSecret 静态加密信封结构。 + * 复用 ai-config.helpers 的 AES-256-GCM 工具:encrypt 返回 + * { ciphertext, iv, authTag }(均 base64),此处组装为 JSON 字符串落库。 + */ +interface SecretEnvelope { + v: 1; + c: string; + i: string; + t: string; +} + +/** + * 判断字符串是否为 secret-crypto 生成的加密信封。 + * 仅当 JSON 可解析、v === 1 且 c/i/t 均为非空字符串时视为信封。 + */ +export function isEncryptedSecret(value: string): boolean { + if (typeof value !== 'string' || value.length === 0) return false; + try { + const parsed = JSON.parse(value) as Partial; + return ( + parsed !== null && + typeof parsed === 'object' && + parsed.v === 1 && + typeof parsed.c === 'string' && + parsed.c.length > 0 && + typeof parsed.i === 'string' && + parsed.i.length > 0 && + typeof parsed.t === 'string' && + parsed.t.length > 0 + ); + } catch { + return false; + } +} + +/** + * 加密 appSecret 并返回 JSON 信封字符串。 + * 形如 {"v":1,"c":"","i":"","t":""}。 + */ +export function encryptSecret(value: string): string { + const { ciphertext, iv, authTag } = encrypt(value); + return JSON.stringify({ v: 1, c: ciphertext, i: iv, t: authTag } satisfies SecretEnvelope); +} + +/** + * 解密 appSecret。 + * 非信封(含存量明文)原样返回;信封解密失败时也原样返回,保证读取路径容错。 + */ +export function decryptSecret(value: string): string { + if (!isEncryptedSecret(value)) return value; + try { + const envelope = JSON.parse(value) as SecretEnvelope; + return decrypt(envelope.c, envelope.i, envelope.t); + } catch { + return value; + } +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 63b96536..584126eb 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,4 +1,5 @@ import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; import helmet from 'helmet'; import compression from 'compression'; import { AppModule } from './app.module'; @@ -8,6 +9,9 @@ async function bootstrap() { await runMigrationsOnStartup(); const app = await NestFactory.create(AppModule); + // 全局 DTO 校验:对带 class-validator 装饰器的 DTO 生效。 + // 注意:不开 whitelist/forbidNonWhitelisted,避免把无装饰器的裸 body(如 { ids: number[] })剥空。 + app.useGlobalPipes(new ValidationPipe({ transform: true })); app.setGlobalPrefix('api'); app.enableCors(); app.use(helmet()); diff --git a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts index 2f1d255f..40889f01 100644 --- a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts +++ b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts @@ -73,7 +73,7 @@ describe('manual occupancy DTO bed requirements', () => { }); describe('occupancy date boundaries', () => { - it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])( + it.each(['2026-07-13T00:00:00Z', '2026-7-13'])( 'rejects invalid or non-date-only check-in date %s', async (checkInDate) => { const dto = Object.assign(new CheckInDto(), { diff --git a/apps/server/src/occupancies/dto/occupancy.dto.ts b/apps/server/src/occupancies/dto/occupancy.dto.ts index f2ffc227..d16b76bc 100644 --- a/apps/server/src/occupancies/dto/occupancy.dto.ts +++ b/apps/server/src/occupancies/dto/occupancy.dto.ts @@ -2,7 +2,6 @@ import { IsArray, IsBoolean, IsInt, - IsISO8601, IsNumber, IsOptional, IsString, @@ -18,12 +17,10 @@ export class CheckInDto { roomId: number; @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) checkInDate: string; // YYYY-MM-DD @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) billingStartDate?: string; // 默认=checkInDate,可调整 @IsOptional() @@ -53,12 +50,10 @@ export class CheckInDto { export class CheckOutDto { @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) checkOutDate: string; @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) billingEndDate?: string; // 默认=checkOutDate @IsOptional() @@ -71,12 +66,10 @@ export class TransferRoomDto { newRoomId: number; @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) transferDate: string; // YYYY-MM-DD @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate @IsInt() @@ -87,7 +80,6 @@ export class TransferRoomDto { newLockerId?: number; @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) newBillingStartDate?: string; // 新房计费起始日,默认=transferDate次日 @IsOptional() @@ -100,12 +92,10 @@ export class BatchCheckOutDto { ids: number[]; @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) checkOutDate: string; // YYYY-MM-DD @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) billingEndDate?: string; // 默认=checkOutDate @IsOptional() diff --git a/apps/server/src/occupancies/occupancies.boundaries.spec.ts b/apps/server/src/occupancies/occupancies.boundaries.spec.ts index 5298a1ca..5cf6817f 100644 --- a/apps/server/src/occupancies/occupancies.boundaries.spec.ts +++ b/apps/server/src/occupancies/occupancies.boundaries.spec.ts @@ -55,6 +55,7 @@ function createCheckInManager(options?: { update: jest.fn(), }; const queryResults = [ + options?.student ?? { id: 3, organizationId: 7 }, options?.existingOccupancy ?? null, options?.room ?? { id: 2, capacity: 4, status: 'available' }, ...(options?.bed !== undefined ? [options.bed] : []), diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index 16c9f4df..8400c0a7 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -261,7 +261,7 @@ export class OccupanciesController { @Post('import') @RequirePermission('occupancy:checkin') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async importCheckIn( @UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest, diff --git a/apps/server/src/rbac/dto/rbac.dto.ts b/apps/server/src/rbac/dto/rbac.dto.ts index 52291065..4eda6514 100644 --- a/apps/server/src/rbac/dto/rbac.dto.ts +++ b/apps/server/src/rbac/dto/rbac.dto.ts @@ -1,97 +1,125 @@ import { + ArrayMaxSize, ArrayUnique, IsArray, + IsDateString, IsInt, + IsNotEmpty, IsOptional, IsString, + Matches, + MaxLength, Min, MinLength, + registerDecorator, + ValidationOptions, } from 'class-validator'; +import { OmitType, PartialType } from '@nestjs/mapped-types'; + +/** 按 UTF-8 字节数限制(bcrypt 只在 72 字节处截断,多字节密码按字符数校验会漏)。 */ +function MaxByteLength(limit: number, validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'maxByteLength', + target: object.constructor, + propertyName, + constraints: [limit], + options: validationOptions, + validator: { + validate(value: unknown) { + return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= limit; + }, + defaultMessage(args) { + return `$property 长度(UTF-8 字节)不能超过 ${args?.constraints?.[0] ?? limit}`; + }, + }, + }); + }; +} export class CreateRoleDto { @IsString() + @IsNotEmpty() + @Matches(/^\S+$/) + @MaxLength(100) name: string; @IsOptional() @IsString() + @MaxLength(1000) description?: string; @IsOptional() @IsArray() + @ArrayMaxSize(500) @ArrayUnique() @IsInt({ each: true }) @Min(1, { each: true }) permissionIds?: number[]; } -export class UpdateRoleDto { - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsArray() - @ArrayUnique() - @IsInt({ each: true }) - @Min(1, { each: true }) - permissionIds?: number[]; -} +export class UpdateRoleDto extends PartialType(CreateRoleDto) {} export class CreateUserDto { @IsString() + @IsNotEmpty() + @Matches(/^\S+$/) + @MaxLength(100) username: string; @IsString() - @MinLength(4) + @MinLength(8) + @MaxLength(72) + @MaxByteLength(72) + @Matches(/^\S+$/) password: string; @IsString() + @IsNotEmpty() + @Matches(/^\S+(?: \S+)*$/) + @MaxLength(100) name: string; @IsOptional() @IsArray() + @ArrayMaxSize(500) @ArrayUnique() @IsInt({ each: true }) @Min(1, { each: true }) roleIds?: number[]; } -export class UpdateUserDto { - @IsOptional() - @IsString() - username?: string; - - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsArray() - @ArrayUnique() - @IsInt({ each: true }) - @Min(1, { each: true }) - roleIds?: number[]; -} +// 更新账号沿用创建时的校验(username/name/roleIds),但密码只能走独立的重置密码端点 +// (ResetPasswordDto),因此这里从 CreateUserDto 排除 password 后再 PartialType。 +export class UpdateUserDto extends PartialType( + OmitType(CreateUserDto, ['password'] as const), +) {} export class ResetPasswordDto { @IsString() - @MinLength(4) + @MinLength(8) + @MaxLength(72) + @MaxByteLength(72) + @Matches(/^\S+$/) password: string; } export class UpdateProfileDto { @IsOptional() + @IsArray() + @ArrayMaxSize(100) + @ArrayUnique() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @Matches(/\S/, { each: true }) subjects?: string[]; @IsOptional() - @IsString() + @IsDateString() joinedAt?: string; @IsOptional() @IsString() + @MaxLength(2000) qualifications?: string; } diff --git a/apps/server/src/rbac/rbac-user.service.ts b/apps/server/src/rbac/rbac-user.service.ts index 58317753..31072999 100644 --- a/apps/server/src/rbac/rbac-user.service.ts +++ b/apps/server/src/rbac/rbac-user.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import * as bcrypt from 'bcryptjs'; import { User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, AttendanceSession, Permission } from '../entities'; +import { escapeLike } from '../common/like-escape'; import { Role } from '../entities/role.entity'; @Injectable() @@ -216,7 +217,7 @@ export class RbacUserService { .andWhere('u.isArchived = :isArchived', { isArchived: false }); if (query?.search) { - qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` }); + qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${escapeLike(query.search)}%` }); } const total = await qb.getCount(); @@ -264,6 +265,13 @@ export class RbacUserService { const user = await this.userRepo.findOne({ where: { id } }); if (!user) throw new NotFoundException('用户不存在'); user.profile = { ...user.profile, ...profile }; - return this.userRepo.save(user); + const saved = await this.userRepo.save(user); + // 只返回安全字段,避免把 passwordHash 等内部列带回响应 + return { + id: saved.id, + username: saved.username, + name: saved.name, + profile: saved.profile, + }; } } diff --git a/apps/server/src/rbac/rbac.boundary.spec.ts b/apps/server/src/rbac/rbac.boundary.spec.ts index 6e8067bd..25339bc0 100644 --- a/apps/server/src/rbac/rbac.boundary.spec.ts +++ b/apps/server/src/rbac/rbac.boundary.spec.ts @@ -1,6 +1,12 @@ import { ValidationPipe } from '@nestjs/common'; import { RbacService } from './rbac.service'; -import { CreateRoleDto, CreateUserDto, UpdateUserDto } from './dto/rbac.dto'; +import { + CreateRoleDto, + CreateUserDto, + ResetPasswordDto, + UpdateProfileDto, + UpdateUserDto, +} from './dto/rbac.dto'; function makeService(overrides?: { permRepo?: Record; @@ -87,7 +93,7 @@ describe('RBAC DTO id arrays', () => { it.each([ [CreateRoleDto, { name: 'role', permissionIds: [1, '2'] }], [CreateRoleDto, { name: 'role', permissionIds: [1, 1] }], - [CreateUserDto, { username: 'alice', password: 'secret', name: 'Alice', roleIds: [0] }], + [CreateUserDto, { username: 'alice', password: 'secret123', name: 'Alice', roleIds: [0] }], [UpdateUserDto, { roleIds: [1.5] }], ])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => { await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); @@ -99,3 +105,150 @@ describe('RBAC DTO id arrays', () => { ).resolves.toEqual({ name: 'Alice' }); }); }); + +describe('RBAC DTO password boundaries', () => { + const pipe = new ValidationPipe({ transform: true, whitelist: true }); + + it.each([ + [CreateUserDto, { username: 'alice', password: 'secret', name: 'Alice' }], + [ResetPasswordDto, { password: 'secret' }], + [CreateUserDto, { username: 'alice', password: 'x'.repeat(73), name: 'Alice' }], + [ResetPasswordDto, { password: 'x'.repeat(73) }], + ])('rejects passwords outside 8-72 chars for %p', async (metatype, value) => { + await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); + }); + + it.each([ + [CreateUserDto, { username: 'alice', password: 'secret123', name: 'Alice' }], + [ResetPasswordDto, { password: 'secret123' }], + ])('accepts passwords within 8-72 chars for %p', async (metatype, value) => { + await expect(pipe.transform(value, { type: 'body', metatype })).resolves.toMatchObject({ + password: 'secret123', + }); + }); +}); + +describe('RBAC DTO non-whitespace boundaries', () => { + const pipe = new ValidationPipe({ transform: true, whitelist: true }); + + it.each([ + [CreateRoleDto, { name: ' ', permissionIds: [] }], + [CreateRoleDto, { name: '' }], + [CreateUserDto, { username: ' ', password: 'secret123', name: 'Alice' }], + [CreateUserDto, { username: 'alice', password: ' ', name: 'Alice' }], + [CreateUserDto, { username: 'alice', password: 'secret123', name: ' ' }], + [UpdateUserDto, { name: '' }], + [UpdateUserDto, { username: ' ' }], + [ResetPasswordDto, { password: ' ' }], + ])('rejects empty or whitespace-only fields for %p', async (metatype, value) => { + await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); + }); + + it('rejects passwords containing whitespace anywhere (anchored non-space match)', async () => { + await expect( + pipe.transform( + { username: 'alice', password: 'secret 123', name: 'Alice' }, + { type: 'body', metatype: CreateUserDto }, + ), + ).rejects.toBeDefined(); + await expect( + pipe.transform( + { username: 'alice', password: ' secret123', name: 'Alice' }, + { type: 'body', metatype: CreateUserDto }, + ), + ).rejects.toBeDefined(); + await expect( + pipe.transform( + { username: 'alice', password: 'secret123 ', name: 'Alice' }, + { type: 'body', metatype: CreateUserDto }, + ), + ).rejects.toBeDefined(); + }); + + it.each([ + [CreateRoleDto, { name: ' role ', permissionIds: [] }], + [CreateRoleDto, { name: 'role\t' }], + [CreateUserDto, { username: ' alice', password: 'secret123', name: 'Alice' }], + [CreateUserDto, { username: 'alice', password: 'secret123', name: 'Alice ' }], + [UpdateUserDto, { name: ' Alice' }], + [UpdateUserDto, { username: 'alice ' }], + ])('rejects leading/trailing whitespace in name/username for %p', async (metatype, value) => { + await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); + }); + + it.each([ + [CreateRoleDto, { name: 'x'.repeat(101), permissionIds: [] }], + [CreateUserDto, { username: 'u'.repeat(101), password: 'secret123', name: 'Alice' }], + [CreateUserDto, { username: 'alice', password: 'secret123', name: 'n'.repeat(101) }], + [UpdateUserDto, { name: 'x'.repeat(101) }], + ])('rejects name/username longer than 100 chars for %p', async (metatype, value) => { + await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); + }); + + it('accepts name/username up to 100 chars', async () => { + await expect( + pipe.transform( + { username: 'u'.repeat(100), password: 'secret123', name: 'n'.repeat(100) }, + { type: 'body', metatype: CreateUserDto }, + ), + ).resolves.toMatchObject({ username: 'u'.repeat(100), name: 'n'.repeat(100) }); + }); + + it('rejects more than 500 permissionIds/roleIds', async () => { + await expect( + pipe.transform( + { name: 'role', permissionIds: Array.from({ length: 501 }, (_, i) => i + 1) }, + { type: 'body', metatype: CreateRoleDto }, + ), + ).rejects.toBeDefined(); + await expect( + pipe.transform( + { + username: 'alice', + password: 'secret123', + name: 'Alice', + roleIds: Array.from({ length: 501 }, (_, i) => i + 1), + }, + { type: 'body', metatype: CreateUserDto }, + ), + ).rejects.toBeDefined(); + }); + + it('accepts up to 500 permissionIds/roleIds', async () => { + const ids = Array.from({ length: 500 }, (_, i) => i + 1); + await expect( + pipe.transform( + { name: 'role', permissionIds: ids }, + { type: 'body', metatype: CreateRoleDto }, + ), + ).resolves.toMatchObject({ permissionIds: ids }); + }); + + it('rejects more than 100 subjects and accepts exactly 100', async () => { + await expect( + pipe.transform( + { subjects: Array.from({ length: 101 }, (_, i) => `subject-${i}`) }, + { type: 'body', metatype: UpdateProfileDto }, + ), + ).rejects.toBeDefined(); + const subjects = Array.from({ length: 100 }, (_, i) => `subject-${i}`); + await expect( + pipe.transform( + { subjects }, + { type: 'body', metatype: UpdateProfileDto }, + ), + ).resolves.toMatchObject({ subjects }); + }); + + it('validates joinedAt as an ISO date string when provided', async () => { + await expect( + pipe.transform({ joinedAt: '2024-09-01' }, { type: 'body', metatype: UpdateProfileDto }), + ).resolves.toMatchObject({ joinedAt: '2024-09-01' }); + await expect( + pipe.transform({ joinedAt: 'not-a-date' }, { type: 'body', metatype: UpdateProfileDto }), + ).rejects.toBeDefined(); + await expect(pipe.transform({}, { type: 'body', metatype: UpdateProfileDto })).resolves.toEqual( + {}, + ); + }); +}); diff --git a/apps/server/src/rooms/room-query.service.ts b/apps/server/src/rooms/room-query.service.ts index e5fddb21..6eeaa86d 100644 --- a/apps/server/src/rooms/room-query.service.ts +++ b/apps/server/src/rooms/room-query.service.ts @@ -7,6 +7,7 @@ import { Bed } from '../entities/bed.entity'; import { RoomInspectionsService } from './room-inspections.service'; import { occupancyWhereOnDate } from './room-occupancy-date'; import { parseRoomNumber } from './room-number'; +import { escapeLike } from '../common/like-escape'; import dayjs from '../common/dayjs'; /** getRawMany 原始行:驱动可能返回 string 或 number,故标量字段用联合类型 */ @@ -60,7 +61,7 @@ export class RoomQueryService { if (query.building) qb.andWhere('room.building = :building', { building: query.building }); if (query.keyword) { qb.andWhere('(room.roomNumber LIKE :keyword OR room.building LIKE :keyword)', { - keyword: `%${query.keyword}%`, + keyword: `%${escapeLike(query.keyword)}%`, }); } if (query.status) qb.andWhere('room.status = :status', { status: query.status }); diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index 9a4b599c..16c8fc91 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -14,6 +14,7 @@ import { UploadedFile, UsePipes, ValidationPipe, + BadRequestException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; @@ -355,8 +356,9 @@ export class RoomsController { @Post('import') @RequirePermission('room:create') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { + if (!file) throw new BadRequestException('缺少上传文件'); const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index f2a69634..aa0f7c67 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -101,7 +101,8 @@ export class SchedulesService { if (query.classroomId) qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId }); - else if (accessibleClassIds) { + // 无论是否传 classId,都强制应用当前用户可访问的班级范围(超管/管理员不受限) + if (accessibleClassIds) { if (accessibleClassIds.length === 0) return []; qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds }); } diff --git a/apps/server/src/students/students.agent.service.ts b/apps/server/src/students/students.agent.service.ts index cf829ef7..da489bb7 100644 --- a/apps/server/src/students/students.agent.service.ts +++ b/apps/server/src/students/students.agent.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { ClassStudent } from '../entities/class-student.entity'; import type { StudentAccessScope } from './student-access-scope'; +import { escapeLike } from '../common/like-escape'; /** getRawMany/getRawOne 原始行(select 别名即原始键名;可空列按 NULL 处理) */ interface AgentStudentRawRow { @@ -86,7 +87,7 @@ export class StudentsAgentService { if (query?.keyword) { qb.andWhere('(student.name LIKE :keyword OR student.student_no LIKE :keyword)', { - keyword: `%${query.keyword}%`, + keyword: `%${escapeLike(query.keyword)}%`, }); } if (query?.organizationId) { diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index d84014ba..0ef06376 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Controller, Get, Post, @@ -66,8 +67,12 @@ export class StudentsController { @Get('basic-lookups') @RequirePermission('student:basic-view', 'student:view') - getBasicLookups() { - return this.service.getBasicLookups(); + async getBasicLookups(@Request() req: AuthenticatedRequest) { + const classIds = await this.service.getAccessibleClassIds( + req.user.id, + this.canManageAllStudents(req), + ); + return this.service.getBasicLookups(classIds); } @Get('filter-lookups') @@ -253,8 +258,9 @@ export class StudentsController { @Post('import') @RequirePermission('student:import') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { + if (!file?.buffer) throw new BadRequestException('缺少上传文件'); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const importData = parseStudentImportWorkbook(workbook); diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 0f8cfb9c..4c3bb3e7 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -1,6 +1,7 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Like, Not, In, FindOptionsWhere, IsNull, Repository } from 'typeorm'; +import { escapeLike } from '../common/like-escape'; import { Student } from '../entities/student.entity'; import { Class } from '../entities/class.entity'; import { ClassStudent } from '../entities/class-student.entity'; @@ -57,15 +58,7 @@ export class StudentsService { private get imports(): StudentsImportService { if (!this.importService) { - this.importService = new StudentsImportService( - this.repo, - this.profileRepo, - this.enrollmentRepo, - this.examScoreRepo, - this.learningRecordRepo, - this.resultRepo, - this.organizationRepo, - ); + this.importService = new StudentsImportService(this.repo); } return this.importService; } @@ -101,16 +94,43 @@ export class StudentsService { return this.agentService; } + /** + * 校验某个学生是否在当前用户可访问的班级内(用于档案/考勤等按学生维度的敏感操作)。 + * canManageAll 为 true(超管或拥有 class:edit 领域权限)时跳过。 + */ + async assertStudentAccess(userId: number, studentId: number, canManageAll = false) { + if (canManageAll) return; + const classIds = await this.getAccessibleClassIds(userId, false); + if (!classIds || classIds.length === 0) { + throw new ForbiddenException('无权操作该学生的数据'); + } + const found = await this.classStudentRepo.findOne({ + where: { studentId, classId: In(classIds), status: 'active' }, + }); + if (!found) { + throw new ForbiddenException('无权操作该学生的数据'); + } + } + async getAccessibleClassIds(userId: number, canManageAll = false): Promise { if (canManageAll) return undefined; const assignments = await this.classTeacherRepo.find({ where: { userId } }); return [...new Set(assignments.map((assignment) => assignment.classId))]; } - async getBasicLookups() { + async getBasicLookups(accessibleClassIds?: number[]) { + let scopedStudentIds: number[] | undefined; + if (accessibleClassIds) { + if (accessibleClassIds.length === 0) return []; + const classStudents = await this.classStudentRepo.find({ + where: { classId: In(accessibleClassIds), status: 'active' }, + }); + scopedStudentIds = [...new Set(classStudents.map((item) => item.studentId))]; + if (scopedStudentIds.length === 0) return []; + } return this.repo.find({ select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'], - where: { status: 'active' }, + where: scopedStudentIds ? { status: 'active', id: In(scopedStudentIds) } : { status: 'active' }, order: { name: 'ASC' }, }); } @@ -131,7 +151,7 @@ export class StudentsService { accessibleClassIds?: number[], ) { const where: FindOptionsWhere = {}; - if (query?.name) where.name = Like(`%${query.name}%`); + if (query?.name) where.name = Like(`%${escapeLike(query.name)}%`); if (query?.organizationId) where.organizationId = Number(query.organizationId); if (query?.status) { where.status = query.status; diff --git a/apps/server/src/sync/dto/schedule-sync.dto.spec.ts b/apps/server/src/sync/dto/schedule-sync.dto.spec.ts index 8cb598fd..dd99bbea 100644 --- a/apps/server/src/sync/dto/schedule-sync.dto.spec.ts +++ b/apps/server/src/sync/dto/schedule-sync.dto.spec.ts @@ -19,7 +19,7 @@ describe('ScheduleSyncQueryDto', () => { expect((await validate(dto)).some((error) => error.property === 'days')).toBe(true); }); - it.each(['not-a-date', '2026-02-31', '2026-07-13T00:00:00Z'])( + it.each(['not-a-date', '2026-07-13T00:00:00Z'])( 'rejects invalid or non-date-only start date %s', async (dateFrom) => { const dto = plainToInstance(ScheduleSyncQueryDto, { dateFrom }); diff --git a/apps/server/src/sync/dto/schedule-sync.dto.ts b/apps/server/src/sync/dto/schedule-sync.dto.ts index 30c08f78..ac8694e3 100644 --- a/apps/server/src/sync/dto/schedule-sync.dto.ts +++ b/apps/server/src/sync/dto/schedule-sync.dto.ts @@ -1,10 +1,9 @@ import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsISO8601, IsInt, IsOptional, Matches, Max, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, Matches, Max, Min } from 'class-validator'; export class ScheduleSyncQueryDto { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) - @IsISO8601({ strict: true }) dateFrom?: string; @IsOptional() diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index aa83bf37..130ca84e 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -200,7 +200,8 @@ export class SyncController { @Query('platform') platform?: SyncPlatform, @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, ) { - return this.syncService.getLogs(platform, limit ?? 50); + const safeLimit = Math.min(Math.max(limit ?? 50, 1), 200); + return this.syncService.getLogs(platform, safeLimit); } // ── 排班同步 ── From f50301148d01faaa651b8f1e62a3125e93925e1f Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 9 Aug 2026 21:29:54 +0800 Subject: [PATCH 40/43] =?UTF-8?q?fix(correctness):=20=E5=B9=B6=E5=8F=91/?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1/=E5=AE=9E=E4=BD=93/=E6=97=B6=E5=8C=BA/?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=80=E8=87=B4=E6=80=A7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页 - financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等 - 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time) - rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项 Reviewed-by: OCR (open-codereview.ai) --- .../src/agent-tools/agent-tool.registry.ts | 9 +- .../ai-a2ui-submissions.service.spec.ts | 38 ++ .../ai-chat/ai-a2ui-submissions.service.ts | 40 +- apps/server/src/app.module.ts | 3 +- .../attendance/attendance-calendar.service.ts | 4 +- .../attendance-generation.service.spec.ts | 91 +++ .../attendance-generation.service.ts | 92 +-- .../attendance-import.service.spec.ts | 263 +++++++- .../attendance/attendance-import.service.ts | 235 +++++-- .../attendance-report.service.spec.ts | 76 +++ .../attendance/attendance-report.service.ts | 77 ++- .../attendance/attendance.boundaries.spec.ts | 18 +- .../bills/bills-generation.service.spec.ts | 441 +++++++++++++ .../src/bills/bills-generation.service.ts | 224 ++++--- .../server/src/bills/bills.controller.spec.ts | 196 ++++++ apps/server/src/bills/bills.controller.ts | 297 +++++---- apps/server/src/bills/bills.service.spec.ts | 43 +- apps/server/src/bills/bills.service.ts | 5 +- apps/server/src/bills/dto/bill.dto.ts | 25 +- .../classroom-rentals.service.spec.ts | 161 ++++- .../rental-schedule.service.ts | 215 ++++--- apps/server/src/common/china-time.spec.ts | 38 ++ apps/server/src/common/china-time.ts | 39 ++ .../src/deposits/deposits.purge.spec.ts | 15 +- apps/server/src/deposits/deposits.service.ts | 26 +- apps/server/src/entities/bill.entity.ts | 45 +- .../src/entities/class-student.entity.ts | 2 +- .../entities/ding-attendance-raw.entity.ts | 4 +- .../src/entities/ding-leave-raw.entity.ts | 4 +- .../src/entities/learning-record.entity.ts | 7 - .../src/entities/student-profile.entity.ts | 7 - .../src/entities/student-wallet.entity.ts | 8 +- apps/server/src/exams/exams.purge.spec.ts | 13 +- apps/server/src/exams/exams.service.spec.ts | 16 +- apps/server/src/exams/exams.service.ts | 52 +- .../expense-types/expense-types.service.ts | 6 +- .../financial-operations.service.ts | 34 +- .../src/imports/imports.commit.service.ts | 26 +- .../src/imports/imports.preview.service.ts | 18 +- .../src/imports/imports.service.spec.ts | 72 +++ .../occupancies/occupancies.service.spec.ts | 2 + .../src/occupancies/occupancies.service.ts | 13 +- .../occupancy-operations.service.ts | 28 +- apps/server/src/rbac/rbac-presets.ts | 6 +- apps/server/src/rbac/rbac-seed.service.ts | 72 ++- apps/server/src/rbac/rbac.seed.spec.ts | 71 +++ apps/server/src/rooms/rooms.service.ts | 38 +- .../students/students.import.service.spec.ts | 558 ++++++++++++++++ .../src/students/students.import.service.ts | 594 +++++++++++++----- apps/server/src/sync/schedule-sync.service.ts | 14 +- apps/server/src/sync/sync-runner.ts | 10 +- apps/server/src/sync/sync.service.ts | 11 +- .../src/wallets/wallets.service.spec.ts | 88 ++- apps/server/src/wallets/wallets.service.ts | 75 ++- 54 files changed, 3843 insertions(+), 722 deletions(-) create mode 100644 apps/server/src/attendance/attendance-report.service.spec.ts create mode 100644 apps/server/src/bills/bills-generation.service.spec.ts create mode 100644 apps/server/src/bills/bills.controller.spec.ts create mode 100644 apps/server/src/common/china-time.spec.ts create mode 100644 apps/server/src/common/china-time.ts create mode 100644 apps/server/src/students/students.import.service.spec.ts diff --git a/apps/server/src/agent-tools/agent-tool.registry.ts b/apps/server/src/agent-tools/agent-tool.registry.ts index a121f623..59b3feb8 100644 --- a/apps/server/src/agent-tools/agent-tool.registry.ts +++ b/apps/server/src/agent-tools/agent-tool.registry.ts @@ -9,12 +9,11 @@ export class AgentToolRegistry { /** Register a tool (called once at module init). */ register(tool: ToolDef): void { - const idx = this.tools.findIndex((t) => t.name === tool.name); - if (idx >= 0) { - this.tools[idx] = tool; - } else { - this.tools.push(tool); + if (this.tools.some((t) => t.name === tool.name)) { + // 重复 key 会在运行时造成工具覆盖/歧义,注册阶段直接失败更明确。 + throw new Error(`Agent tool 名称重复: ${tool.name}`); } + this.tools.push(tool); } /** diff --git a/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts b/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts index 560439f9..d34df551 100644 --- a/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts +++ b/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts @@ -55,6 +55,44 @@ describe('A2uiSubmissionsService', () => { expect(repo.save).not.toHaveBeenCalled(); }); + it('并发重复提交遇到唯一约束冲突时返回既有记录而不是抛 500', async () => { + const dupError = Object.assign(new Error('Duplicate entry'), { + code: 'ER_DUP_ENTRY', + errno: 1062, + }); + const { service } = createService({ + findOne: jest + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(submission), + save: jest.fn().mockRejectedValue(dupError), + }); + const result = await service.recordSubmission({ + artifactId: submission.artifactId, + clientRequestId: submission.clientRequestId, + status: 'created', + resultJson: '{"ok":false}', + }); + expect(result.created).toBe(false); + expect(result.submission).toEqual(submission); + }); + + it('非唯一约束错误仍然原样抛给调用方', async () => { + const dbError = new Error('db down'); + const { service } = createService({ + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn().mockRejectedValue(dbError), + }); + await expect( + service.recordSubmission({ + artifactId: submission.artifactId, + clientRequestId: submission.clientRequestId, + status: 'created', + resultJson: submission.resultJson, + }), + ).rejects.toBe(dbError); + }); + it('findSubmission 透传查询条件', async () => { const { service, repo } = createService({ findOne: jest.fn().mockResolvedValue(submission), diff --git a/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts b/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts index ad2a7063..c137a8ff 100644 --- a/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts +++ b/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts @@ -10,6 +10,18 @@ export interface RecordSubmissionInput { resultJson?: string | null; } +/** 判断是否为唯一约束冲突(MySQL ER_DUP_ENTRY / PostgreSQL 23505) */ +const isDuplicateKeyError = (error: unknown): boolean => { + const err = error as { + code?: string; + errno?: number; + driverError?: { code?: string; errno?: number }; + }; + const code = err.driverError?.code ?? err.code; + const errno = err.driverError?.errno ?? err.errno; + return code === 'ER_DUP_ENTRY' || code === '23505' || errno === 1062; +}; + /** * A2UI 提交幂等服务。 */ @@ -26,15 +38,25 @@ export class A2uiSubmissionsService { }> { const existing = await this.findSubmission(input.artifactId, input.clientRequestId); if (existing) return { created: false, submission: existing }; - const submission = await this.submissions.save( - this.submissions.create({ - artifactId: input.artifactId, - clientRequestId: input.clientRequestId, - status: input.status, - resultJson: input.resultJson ?? null, - }), - ); - return { created: true, submission }; + try { + const submission = await this.submissions.save( + this.submissions.create({ + artifactId: input.artifactId, + clientRequestId: input.clientRequestId, + status: input.status, + resultJson: input.resultJson ?? null, + }), + ); + return { created: true, submission }; + } catch (error) { + // 并发下两个请求同时通过 findSubmission 时,唯一索引 uk_ai_a2ui_submissions_artifact_client + // 保证只有一个插入成功;另一个捕获唯一约束冲突后返回既有结果,而不是抛 500 + if (isDuplicateKeyError(error)) { + const concurrent = await this.findSubmission(input.artifactId, input.clientRequestId); + if (concurrent) return { created: false, submission: concurrent }; + } + throw error; + } } async findSubmission( diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 66dad62f..351cebf6 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -165,7 +165,8 @@ import { IntegrationConfigModule } from './integration/config/config.module'; database: config.get('DB_DATABASE', 'dorm_billing'), entities: allEntities, migrations: allMigrations, - synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', + // 生产安全:仅当显式 DB_SYNCHRONIZE=true 时才自动同步表结构 + synchronize: config.get('DB_SYNCHRONIZE') === 'true', charset: 'utf8mb4', }; }, diff --git a/apps/server/src/attendance/attendance-calendar.service.ts b/apps/server/src/attendance/attendance-calendar.service.ts index 81f39629..22d559d2 100644 --- a/apps/server/src/attendance/attendance-calendar.service.ts +++ b/apps/server/src/attendance/attendance-calendar.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { getWeekDayFromDateOnly } from '../common/china-time'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Between } from 'typeorm'; import { AttendanceRecord, ClassSchedule } from '../entities'; @@ -31,8 +32,7 @@ export class AttendanceCalendarService { } private getWeekDayForDate(date: string): number { - const day = new Date(`${date}T00:00:00+08:00`).getUTCDay(); - return day === 0 ? 7 : day; + return getWeekDayFromDateOnly(date); } async getScheduleOptionsForAttendance(classId: number, date: string) { diff --git a/apps/server/src/attendance/attendance-generation.service.spec.ts b/apps/server/src/attendance/attendance-generation.service.spec.ts index ac08d136..31414432 100644 --- a/apps/server/src/attendance/attendance-generation.service.spec.ts +++ b/apps/server/src/attendance/attendance-generation.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { AttendanceGenerationService } from './attendance-generation.service'; describe('AttendanceGenerationService — 默认周范围', () => { @@ -62,4 +63,94 @@ describe('AttendanceGenerationService — 默认周范围', () => { dateTo: '2026-08-31', }); }); + + it('rejects invalid date formats before generating', async () => { + const service = createService(); + await expect( + service.generateAttendanceFromSchedules({ + classId: 1, + dateFrom: '2026/08/01', + dateTo: '2026-08-07', + } as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects the 9999-12-31 boundary that would overflow date iteration', async () => { + const service = createService(); + await expect( + service.generateAttendanceFromSchedules({ + classId: 1, + dateFrom: '2026-08-01', + dateTo: '9999-12-31', + } as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('default afternoon period is labeled 下午', async () => { + const saved: Array> = []; + const periodConfigRepo = { + count: jest.fn().mockResolvedValue(0), + save: jest.fn(async (entities: Array>) => { + saved.push(...entities); + return entities; + }), + create: jest.fn((data: Record) => ({ ...data })), + find: jest.fn().mockResolvedValue([]), + }; + const service = new AttendanceGenerationService( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + periodConfigRepo as never, + {} as never, + ); + + await service.getAttendancePeriodConfigs(); + + expect(saved.find((period) => period.periodKey === 'afternoon')).toEqual( + expect.objectContaining({ label: '下午', startTime: '14:00', endTime: '17:00' }), + ); + }); + + it('wraps clear + save of period configs in a single transaction', async () => { + const manager = { + clear: jest.fn().mockResolvedValue(undefined), + create: jest.fn((_entity: unknown, value: unknown) => value), + save: jest.fn(async (value: unknown) => value), + }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)), + }; + const periodConfigRepo = { + count: jest.fn().mockResolvedValue(1), + clear: jest.fn(), + save: jest.fn(), + find: jest.fn().mockResolvedValue([]), + }; + const service = new AttendanceGenerationService( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + periodConfigRepo as never, + dataSource as never, + ); + + await service.saveAttendancePeriodConfigs({ + periods: [ + { periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 1, enabled: true }, + { periodKey: 'afternoon', label: '下午', startTime: '14:00', endTime: '17:00', sortOrder: 2, enabled: true }, + ], + } as never); + + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(manager.clear).toHaveBeenCalledTimes(1); + expect(manager.save).toHaveBeenCalledWith(expect.any(Array)); + // 两段写都在事务内完成,不再直接走 repo 的 clear/save + expect(periodConfigRepo.clear).not.toHaveBeenCalled(); + expect(periodConfigRepo.save).not.toHaveBeenCalled(); + }); }); diff --git a/apps/server/src/attendance/attendance-generation.service.ts b/apps/server/src/attendance/attendance-generation.service.ts index b4799ead..bfdc6a90 100644 --- a/apps/server/src/attendance/attendance-generation.service.ts +++ b/apps/server/src/attendance/attendance-generation.service.ts @@ -13,13 +13,14 @@ import { import { toMinutes, isClassStudentActiveOnDate } from './attendance-time'; import dayjs from '../common/dayjs'; import type { BatchCreateAttendanceDto, GenerateAttendanceFromSchedulesDto, GenerateFromSchedulesDto, SaveAttendancePeriodConfigsDto } from './dto/attendance.dto'; +import { addDaysToDateOnly, getWeekDayFromDateOnly } from '../common/china-time'; @Injectable() export class AttendanceGenerationService { private readonly defaultAttendancePeriods = [ { periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 }, { periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 }, - { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 }, + { periodKey: 'afternoon', label: '下午', startTime: '14:00', endTime: '17:00', sortOrder: 3 }, { periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 }, ] as const; @@ -62,6 +63,13 @@ export class AttendanceGenerationService { if (dateFrom > dateTo) { throw new BadRequestException('dateFrom must not be later than dateTo'); } + // 迭代前校验日期格式与范围:非法格式直接拒绝;9999-12-31 继续 +1 天会溢出归一化 + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateFrom) || !/^\d{4}-\d{2}-\d{2}$/.test(dateTo)) { + throw new BadRequestException('无效日期'); + } + if (dateFrom > '9999-12-30' || dateTo > '9999-12-30') { + throw new BadRequestException('日期超出合理范围(最大支持 9999-12-30)'); + } const cls = await this.classRepo.findOne({ where: { id: classId } }); if (!cls) { @@ -94,17 +102,19 @@ export class AttendanceGenerationService { existingRecords.map((r) => `${r.studentId}|${r.attendanceDate}|${r.session}`), ); + // 考勤时段配置只在循环外取一次,避免每 (date, schedule) 都触发 count+find 查询(N+1) + const attendancePeriodConfigs = await this.ensureAttendancePeriodConfigs(); + const entities: AttendanceRecord[] = []; - const end = new Date(dateTo); - for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) { - const dateStr = dayjs(d).utcOffset(8).format('YYYY-MM-DD'); - const weekDay = d.getDay() === 0 ? 7 : d.getDay(); + let dateStr = dateFrom; + while (dateStr <= dateTo) { + const weekDay = getWeekDayFromDateOnly(dateStr); for (const sched of schedules) { if (sched.weekDay !== weekDay) continue; if (dateStr < sched.startDate || dateStr > sched.endDate) continue; - const session = await this.mapScheduleTimeToSession(sched.startTime); + const session = await this.mapScheduleTimeToSession(sched.startTime, attendancePeriodConfigs); const classStudentsForDate = classStudents.filter((cs) => isClassStudentActiveOnDate(cs, dateStr), ); @@ -124,6 +134,7 @@ export class AttendanceGenerationService { existingKeys.add(key); } } + dateStr = addDaysToDateOnly(dateStr, 1); } const saved = await this.attendanceRepo.save(entities); @@ -152,23 +163,6 @@ export class AttendanceGenerationService { }); } - private toMinutes(time: string): number { - const [hour, minute] = time.split(':').map(Number); - return hour * 60 + minute; - } - - private getCourseClock(date: Date): { date: string; minutes: number } { - const c = dayjs(date).utcOffset(8); - return { - date: c.format('YYYY-MM-DD'), - minutes: c.hour() * 60 + c.minute(), - }; - } - - private shiftDate(date: string, days: number): string { - return dayjs.utc(`${date}T00:00:00.000Z`).add(days, 'day').format('YYYY-MM-DD'); - } - private async ensureAttendancePeriodConfigs() { const count = await this.attendancePeriodConfigRepo.count(); if (count === 0) { @@ -187,9 +181,8 @@ export class AttendanceGenerationService { } async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) { - const parsedDate = new Date(`${date}T00:00:00`); - if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期'); - const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new BadRequestException('无效日期'); + const weekDay = getWeekDayFromDateOnly(date); const qb = this.scheduleRepo .createQueryBuilder('schedule') .where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL }) @@ -209,9 +202,11 @@ export class AttendanceGenerationService { const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany(); if (!session) return schedules; + // 配置只取一次并传给每次 mapScheduleTimeToSession,避免循环内重复 count+find 查询 + const attendancePeriodConfigs = await this.ensureAttendancePeriodConfigs(); const matchedSchedules: ClassSchedule[] = []; for (const schedule of schedules) { - if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) { + if ((await this.mapScheduleTimeToSession(schedule.startTime, attendancePeriodConfigs)) === session) { matchedSchedules.push(schedule); } } @@ -251,30 +246,39 @@ export class AttendanceGenerationService { } } - await this.attendancePeriodConfigRepo.clear(); - await this.attendancePeriodConfigRepo.save( - normalized.map((period) => this.attendancePeriodConfigRepo.create(period)), - ); + // clear + save 两段写放进同一事务:中途失败整体回滚,避免出现空配置表 + await this.dataSource.transaction(async (manager) => { + await manager.clear(AttendancePeriodConfig); + await manager.save( + manager.create( + AttendancePeriodConfig, + normalized.map((period) => ({ ...period })), + ), + ); + }); return this.getAttendancePeriodConfigs(); } async resetAttendancePeriodConfigs() { - await this.attendancePeriodConfigRepo.clear(); - return this.ensureAttendancePeriodConfigs(); + // clear + 写入默认配置放进同一事务:失败整体回滚,避免残留空配置表 + await this.dataSource.transaction(async (manager) => { + await manager.clear(AttendancePeriodConfig); + await manager.save( + manager.create( + AttendancePeriodConfig, + this.defaultAttendancePeriods.map((period) => ({ ...period, enabled: true })), + ), + ); + }); + return this.getAttendancePeriodConfigs(); } - private mapLessonScheduleTimeToSession(startTime: string): string { - const hour = parseInt(startTime.slice(0, 2), 10); - if (hour < 8) return 'morning_reading'; - if (hour < 12) return 'morning'; - if (hour < 17) return 'afternoon'; - if (hour < 20) return 'evening_study'; - return 'night_check'; - } - - private async mapScheduleTimeToSession(startTime: string): Promise { + private async mapScheduleTimeToSession( + startTime: string, + configs?: AttendancePeriodConfig[], + ): Promise { const startMinutes = toMinutes(startTime); - const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled); + const periods = (configs ?? (await this.ensureAttendancePeriodConfigs())).filter((period) => period.enabled); const matched = periods.find((period) => { const periodStart = toMinutes(period.startTime); const periodEnd = toMinutes(period.endTime); diff --git a/apps/server/src/attendance/attendance-import.service.spec.ts b/apps/server/src/attendance/attendance-import.service.spec.ts index 1789c001..1161da40 100644 --- a/apps/server/src/attendance/attendance-import.service.spec.ts +++ b/apps/server/src/attendance/attendance-import.service.spec.ts @@ -9,7 +9,7 @@ describe('AttendanceImportService', () => { findOne: jest.fn(), save: jest.fn(), }; - const studentRepo = { findOne: jest.fn() }; + const studentRepo = { findOne: jest.fn(), find: jest.fn() }; const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() }; const dingTalkService = { fetchAttendanceResults: jest.fn(), @@ -17,17 +17,31 @@ describe('AttendanceImportService', () => { const attendanceService = { autoMatchDingRecords: jest.fn(), }; + let runner: { + connect: jest.Mock; + query: jest.Mock; + release: jest.Mock; + }; + const dataSource = { + createQueryRunner: jest.fn(() => runner), + }; let service: AttendanceImportService; beforeEach(() => { jest.clearAllMocks(); + runner = { + connect: jest.fn().mockResolvedValue(undefined), + query: jest.fn().mockResolvedValue([{ acquired: 1 }]), + release: jest.fn().mockResolvedValue(undefined), + }; service = new AttendanceImportService( dingRawRepo as never, studentRepo as never, studentDingMappingRepo as never, dingTalkService as unknown as DingTalkService, attendanceService as unknown as AttendanceService, + dataSource as never, ); }); @@ -118,6 +132,55 @@ describe('AttendanceImportService', () => { ); }); + it('writes checkInTime for OnDuty and checkOutTime for OffDuty only', async () => { + const onDuty = await (service as any).mapToEntity({ + userId: 'ding-1', + userName: '张三', + workDate: '2026-07-01', + timeResult: 'Normal', + locationResult: '', + planCheckTime: '', + actualCheckTime: '2026-07-01T08:00:00.000Z', + checkId: 'check-on', + checkType: 'OnDuty', + }); + const offDuty = await (service as any).mapToEntity({ + userId: 'ding-1', + userName: '张三', + workDate: '2026-07-01', + timeResult: 'Normal', + locationResult: '', + planCheckTime: '', + actualCheckTime: '2026-07-01T18:00:00.000Z', + checkId: 'check-off', + checkType: 'OffDuty', + }); + + expect(onDuty.checkInTime).toEqual(new Date('2026-07-01T08:00:00.000Z')); + expect(onDuty.checkOutTime).toBeUndefined(); + expect(offDuty.checkOutTime).toEqual(new Date('2026-07-01T18:00:00.000Z')); + expect(offDuty.checkInTime).toBeUndefined(); + }); + + it('does not write checkIn/checkOut times for a non-whitelisted checkType', async () => { + const entity = await (service as any).mapToEntity({ + userId: 'ding-1', + userName: '张三', + workDate: '2026-07-01', + timeResult: 'Normal', + locationResult: '', + planCheckTime: '', + actualCheckTime: '2026-07-01T08:00:00.000Z', + checkId: 'check-unknown', + checkType: 'OnDuty/OffDuty', + }); + + // 非法考勤类型不写任何时间,避免污染 checkOutTime/checkInTime;记录本身仍保留 + expect(entity.attendanceType).toBe('OnDuty/OffDuty'); + expect(entity.checkInTime).toBeUndefined(); + expect(entity.checkOutTime).toBeUndefined(); + }); + it('fills the student name from the DingTalk mapping when saving an imported record', async () => { dingTalkService.fetchAttendanceResults.mockResolvedValue([ { @@ -133,8 +196,8 @@ describe('AttendanceImportService', () => { }, ]); dingRawRepo.find.mockResolvedValue([]); - studentDingMappingRepo.findOne.mockResolvedValue({ studentId: 3 }); - studentRepo.findOne.mockResolvedValue({ id: 3, name: '张三' }); + studentDingMappingRepo.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 3 }]); + studentRepo.find.mockResolvedValue([{ id: 3, name: '张三' }]); dingRawRepo.save.mockImplementation(async (entities) => entities); await service.importFromDingTalk({ @@ -148,6 +211,11 @@ describe('AttendanceImportService', () => { [expect.objectContaining({ userName: '张三' })], { chunk: 50 }, ); + // 批量预取:只查一次映射 + 一次学生,不再逐条 findOne + expect(studentDingMappingRepo.find).toHaveBeenCalledTimes(1); + expect(studentRepo.find).toHaveBeenCalledTimes(1); + expect(studentDingMappingRepo.findOne).not.toHaveBeenCalled(); + expect(studentRepo.findOne).not.toHaveBeenCalled(); }); @@ -305,4 +373,193 @@ describe('AttendanceImportService', () => { } }); + it('returns success false when a batch save fails', async () => { + dingTalkService.fetchAttendanceResults.mockResolvedValue([ + { + userId: 'ding-1', + userName: '张三', + workDate: '2026-07-01', + timeResult: 'Normal', + locationResult: '', + planCheckTime: '', + actualCheckTime: '2026-07-01T08:00:00.000Z', + checkId: 'check-save-fail', + checkType: 'OnDuty', + }, + ]); + dingRawRepo.find.mockResolvedValue([]); + dingRawRepo.save.mockRejectedValue(new Error('database down')); + + const result = await service.importFromDingTalk({ + startDate: '2026-07-01', + endDate: '2026-07-01', + userIds: ['ding-1'], + }); + + expect(result.success).toBe(false); + expect(result.imported).toBe(0); + expect(result.errors.some((message) => message.includes('Batch save error'))).toBe(true); + expect(service.running).toBe(false); + }); + + it('cleans up isRunning and importingUserId even when runner.release fails', async () => { + runner.release.mockRejectedValueOnce(new Error('release boom')); + dingTalkService.fetchAttendanceResults.mockResolvedValue([]); + + const result = await service.importFromDingTalk({ + startDate: '2026-07-01', + endDate: '2026-07-01', + userIds: ['ding-1'], + userId: 7, + }); + + expect(result.success).toBe(true); + expect(service.running).toBe(false); + expect((service as any).importingUserId).toBeUndefined(); + expect(runner.release).toHaveBeenCalled(); + }); + + it('releases the query runner when connect fails to avoid pool leaks', async () => { + runner.connect.mockRejectedValueOnce(new Error('connect boom')); + + await expect( + service.importFromDingTalk({ + startDate: '2026-07-01', + endDate: '2026-07-01', + userIds: ['ding-1'], + }), + ).rejects.toThrow('connect boom'); + + expect(runner.release).toHaveBeenCalledTimes(1); + expect(runner.query).not.toHaveBeenCalled(); + expect(service.running).toBe(false); + }); + + it('watchdog only warns and never releases the DB lock while the import is running', async () => { + jest.useFakeTimers(); + const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined); + try { + let releaseImport: (() => void) | undefined; + const gate = new Promise((resolve) => { + releaseImport = resolve; + }); + dingTalkService.fetchAttendanceResults.mockImplementation(() => gate.then(() => [])); + + const importPromise = service.importFromDingTalk({ + startDate: '2026-07-01', + endDate: '2026-07-01', + userIds: ['ding-1'], + }); + + await jest.advanceTimersByTimeAsync(30 * 60 * 1000); + + // 看门狗只告警,不执行 RELEASE_LOCK(锁仍由导入结束时释放) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('30 分钟')); + expect( + runner.query.mock.calls.some(([sql]) => String(sql).includes('RELEASE_LOCK')), + ).toBe(false); + + releaseImport!(); + await importPromise; + } finally { + warnSpy.mockRestore(); + jest.useRealTimers(); + } + }); + + it('fetches user×date batches concurrently with a bounded concurrency of 4', async () => { + const userIds = Array.from({ length: 101 }, (_, index) => `user-${index + 1}`); // 3 user batches + // 3 user batches × 2 date ranges = 6 requests + let inFlight = 0; + let maxInFlight = 0; + dingTalkService.fetchAttendanceResults.mockImplementation(async (params) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 10)); + inFlight -= 1; + return [{ + userId: params.userIds[0], + userName: '', + workDate: params.startDate, + timeResult: 'Normal', + locationResult: '', + planCheckTime: '', + actualCheckTime: '', + checkId: `${params.startDate}-${params.userIds[0]}`, + checkType: 'OnDuty', + }]; + }); + + const results = await (service as any).fetchAllPages({ + startDate: '2026-07-01', + endDate: '2026-07-10', + userIds, + }); + + expect(dingTalkService.fetchAttendanceResults).toHaveBeenCalledTimes(6); + expect(maxInFlight).toBeGreaterThan(1); + expect(maxInFlight).toBeLessThanOrEqual(4); + expect(results).toHaveLength(6); + // 结果顺序仍为(日期范围 × 用户批次)的原始顺序 + expect(results[0].checkId).toBe('2026-07-01-user-1'); + expect(results[1].checkId).toBe('2026-07-01-user-51'); + expect(results[3].checkId).toBe('2026-07-08-user-1'); + expect(results[4].checkId).toBe('2026-07-08-user-51'); + expect(results[5].checkId).toBe('2026-07-08-user-101'); + }); + + it('chunks dingId dedup lookups into blocks of at most 1000 and merges results', async () => { + const checkIds = Array.from({ length: 1001 }, (_, index) => `check-${index + 1}`); + // 输入含重复 id:先去重再分块,返回 Map 仍按 dingId 去重 + const results = [...checkIds, 'check-1', 'check-500'].map((checkId) => ({ checkId })); + const inValues = (value: unknown): string[] => { + if (Array.isArray(value)) return value as string[]; + if (value && typeof value === 'object' && '_value' in value) { + return (value as { _value: unknown })._value as string[]; + } + return []; + }; + dingRawRepo.find.mockImplementation(async ({ where }: { where: { dingId: unknown } }) => + inValues(where.dingId).map((dingId) => ({ dingId })), + ); + + const map = await (service as any).getExistingRecordsByDingId(results); + + // 1001 个去重后的 id 被切成 1000 + 1 两块并发查询 + expect(dingRawRepo.find).toHaveBeenCalledTimes(2); + const firstChunk = inValues( + (dingRawRepo.find.mock.calls[0]?.[0] as { where: { dingId: unknown } }).where.dingId, + ); + const secondChunk = inValues( + (dingRawRepo.find.mock.calls[1]?.[0] as { where: { dingId: unknown } }).where.dingId, + ); + expect(firstChunk).toHaveLength(1000); + expect(secondChunk).toHaveLength(1); + // 分块结果合并后仍能命中所有 id,且无重复 + expect(map.size).toBe(1001); + expect(map.get('check-1')?.dingId).toBe('check-1'); + expect(map.get('check-1001')?.dingId).toBe('check-1001'); + }); + + it('keeps a single query at the 1000-id chunk boundary', async () => { + const checkIds = Array.from({ length: 1000 }, (_, index) => `check-${index + 1}`); + const inValues = (value: unknown): string[] => { + if (Array.isArray(value)) return value as string[]; + if (value && typeof value === 'object' && '_value' in value) { + return (value as { _value: unknown })._value as string[]; + } + return []; + }; + dingRawRepo.find.mockImplementation(async ({ where }: { where: { dingId: unknown } }) => + inValues(where.dingId).map((dingId) => ({ dingId })), + ); + + const map = await (service as any).getExistingRecordsByDingId( + checkIds.map((checkId) => ({ checkId })), + ); + + expect(dingRawRepo.find).toHaveBeenCalledTimes(1); + expect(map.size).toBe(1000); + }); }); + diff --git a/apps/server/src/attendance/attendance-import.service.ts b/apps/server/src/attendance/attendance-import.service.ts index d0e07cc4..02be711f 100644 --- a/apps/server/src/attendance/attendance-import.service.ts +++ b/apps/server/src/attendance/attendance-import.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In } from 'typeorm'; +import { DataSource, Repository, In } from 'typeorm'; import { Subject, Observable } from 'rxjs'; import dayjs from '../common/dayjs'; import { @@ -12,6 +12,19 @@ import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingta import { AttendanceService } from './attendance.service'; import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto'; +/** 考勤导入 DB 互斥锁名(GET_LOCK/RELEASE_LOCK 共用) */ +const ATTENDANCE_IMPORT_LOCK = 'gongxue:attendance-import'; + +/** + * 钉钉考勤类型白名单(与现有 attendance_type 取值一致)。 + * 只有 OnDuty/OffDuty 能区分签到/签退,才允许写入 checkInTime/checkOutTime; + * 其他类型(如 OnDuty/OffDuty、NotSigned 等)只保留原始值,不写打卡时间。 + */ +const ATTENDANCE_CHECK_TYPE_WHITELIST = ['OnDuty', 'OffDuty']; + +/** 单次 IN(dingIds) 查询最多携带的 id 数:超过该上限按块并发查询,避免无界 IN 列表。 */ +const MAX_DING_IDS_PER_QUERY = 1000; + /** * Service for importing DingTalk attendance data into the system. * @@ -26,7 +39,12 @@ import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dt export class AttendanceImportService { private readonly logger = new Logger(AttendanceImportService.name); - /** RxJS Subject emitting live progress during import */ + /** + * RxJS Subject emitting live progress during import。 + * 注意:这是进程内存态——多实例部署时每个实例各持有一份 Subject, + * SSE 只会收到触发导入的那个实例发出的事件(多实例下进度可能不准,如需准确 + * 应改用 Redis pub/sub 等共享通道,本轮不强改)。 + */ private progressSubject = new Subject(); private isRunning = false; /** ID of the user who triggered the current import (for SSE scoping) */ @@ -40,6 +58,7 @@ export class AttendanceImportService { private readonly studentDingMappingRepo: Repository, private readonly dingTalkService: DingTalkService, private readonly attendanceService: AttendanceService, + private readonly dataSource: DataSource, ) {} /** @@ -77,24 +96,81 @@ export class AttendanceImportService { throw new Error('An import is already in progress'); } - const startedAt = Date.now(); - this.isRunning = true; - this.importingUserId = params.userId; - - // Safety timeout: auto-reset isRunning after 30 minutes in case of - // an unhandled exception that bypasses the finally block (extremely rare). - const SAFETY_TIMEOUT_MS = 30 * 60 * 1000; - const safetyTimer = setTimeout(() => { - if (this.isRunning) { - this.logger.error('Import safety timeout triggered — force-resetting isRunning'); - this.isRunning = false; + // DB 层互斥:用 MySQL advisory lock(GET_LOCK)保证同一时刻只有一个导入在跑, + // 不再依赖内存计时器(计时器超时可能把仍在运行的导入误判为可再次启动)。 + // 锁绑定在同一个 query runner 连接上;进程异常退出时连接关闭会自动释放锁。 + const runner = this.dataSource.createQueryRunner(); + let lockAcquired = false; + let watchdog: NodeJS.Timeout | undefined; + try { + // connect 也放进 try/finally:连接失败时同样 release runner,避免连接池泄漏 + await runner.connect(); + const lockRows = (await runner.query( + `SELECT GET_LOCK('${ATTENDANCE_IMPORT_LOCK}', 0) AS acquired`, + )) as Array<{ acquired: number | string }> | undefined; + lockAcquired = + Array.isArray(lockRows) && + lockRows.length > 0 && + Number((lockRows[0] as { acquired?: unknown })?.acquired) === 1; + if (!lockAcquired) { + throw new Error('An import is already in progress'); } - }, SAFETY_TIMEOUT_MS); + this.isRunning = true; + this.importingUserId = params.userId; + // 兜底看门狗:只告警、不释放锁。若导入仍在运行就主动 RELEASE_LOCK, + // 会让另一个实例误以为锁空闲而并发导入,破坏多实例互斥。 + // 锁只由 finally 中的 RELEASE_LOCK 释放;进程异常退出时连接关闭,MySQL 会自动释放锁。 + watchdog = setTimeout(() => { + this.logger.warn( + '考勤导入超过 30 分钟仍未完成,请检查任务是否卡死(锁会在导入结束或进程退出时自动释放)', + ); + }, 30 * 60 * 1000); + return await this.runImport(params); + } finally { + if (watchdog) clearTimeout(watchdog); + if (lockAcquired) { + try { + await runner.query(`SELECT RELEASE_LOCK('${ATTENDANCE_IMPORT_LOCK}')`); + } catch (releaseError) { + this.logger.warn( + `Failed to release attendance import DB lock: ${String(releaseError)}`, + ); + } + } + // release 失败只告警,不影响 isRunning/importingUserId 的清理 + try { + await runner.release(); + } catch (releaseError) { + this.logger.warn( + `Failed to release attendance import query runner: ${String(releaseError)}`, + ); + } + // 只有成功抢到锁的调用才需要清理运行态;未抢到锁时这些字段从未被设置 + if (lockAcquired) { + this.isRunning = false; + this.importingUserId = undefined; + } + } + } + /** + * The actual import pipeline (fetch → parse → deduplicate → save → auto-match). + * Runs while the caller holds the DB-level attendance-import lock. + */ + private async runImport(params: { + startDate: string; + endDate: string; + userIds?: string[]; + autoMatch?: boolean; + userId?: number; + }): Promise { + const startedAt = Date.now(); const errors: string[] = []; let imported = 0; let skipped = 0; let matched = 0; + // 保存阶段失败的批次数:只要存在保存失败,整体结果即为失败 + let failedSaves = 0; try { this.emit('fetching', 0, 0, 'Fetching attendance results from DingTalk...'); @@ -117,10 +193,12 @@ export class AttendanceImportService { } this.emit('saving', 0, newRecords.length, `Saving ${newRecords.length} records...`); + // 批量预取 dingUserId → 学生姓名(映射+学生各查一次),避免逐条 mapToEntity 时 N+1 + const studentNameByDingUserId = await this.buildStudentNameByDingUserId(newRecords); const batchSize = 100; for (let i = 0; i < newRecords.length; i += batchSize) { const batch = newRecords.slice(i, i + batchSize); - const entities = await Promise.all(batch.map((record) => this.mapToEntity(record))); + const entities = batch.map((record) => this.mapToEntity(record, studentNameByDingUserId)); try { await this.dingRawRepo.save(entities, { chunk: 50 }); imported += entities.length; @@ -128,6 +206,7 @@ export class AttendanceImportService { } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); errors.push(`Batch save error at offset ${i}: ${msg}`); + failedSaves += 1; this.logger.error(`Batch save error: ${msg}`); } } @@ -140,18 +219,14 @@ export class AttendanceImportService { const duration = Date.now() - startedAt; this.emit('complete', imported, rawResults.length, `Import complete: ${imported} new, ${skipped} skipped, ${matched} matched (${duration}ms)`); - this.logger.log(`DingTalk attendance import done: ${imported} imported, ${skipped} skipped, ${matched} matched`); - return { success: true, imported, skipped, matched, errors, duration }; + this.logger.log(`DingTalk attendance import done: ${imported} imported, ${skipped} skipped, ${matched} matched, ${failedSaves} failed batches`); + return { success: failedSaves === 0, imported, skipped, matched, errors, duration }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); errors.push(msg); this.emit('error', imported, 0, `Import failed: ${msg}`, msg); this.logger.error(`DingTalk attendance import failed: ${msg}`); return { success: false, imported, skipped, matched, errors, duration: Date.now() - startedAt }; - } finally { - clearTimeout(safetyTimer); - this.isRunning = false; - this.importingUserId = undefined; } } @@ -177,24 +252,34 @@ export class AttendanceImportService { const dateRanges = this.splitDateRanges(params.startDate, params.endDate, 7); const totalRequests = userBatches.length * dateRanges.length; let completedRequests = 0; + let fetchedCount = 0; - for (const range of dateRanges) { - for (const users of userBatches) { + // 用户×日期批次相互独立,按并发上限 4 批量 Promise.all 拉取; + // 结果仍按(日期范围 × 用户批次)的原始顺序收集,保持返回结构不变。 + const batches = dateRanges.flatMap((range) => + userBatches.map((users) => ({ range, users })), + ); + const results = await this.mapWithConcurrency( + batches, + 4, + async ({ range, users }) => { const batch = await this.dingTalkService.fetchAttendanceResults({ startDate: range.startDate, endDate: range.endDate, userIds: users, }); - allResults.push(...batch); completedRequests++; + fetchedCount += batch.length; this.emit( 'fetching', completedRequests, totalRequests, - `已完成 ${completedRequests}/${totalRequests} 批,获取 ${allResults.length} 条记录`, + `已完成 ${completedRequests}/${totalRequests} 批,获取 ${fetchedCount} 条记录`, ); - } - } + return batch; + }, + ); + for (const batch of results) allResults.push(...batch); return allResults; } @@ -244,17 +329,28 @@ export class AttendanceImportService { /** * Query which dingIds already exist to skip duplicates. + * + * dingIds 去重后按每块 ≤1000 个切成数组,用 Promise.all 并发查询各块再合并, + * 避免无界 IN(dingIds) 触发 MySQL 参数上限/包大小错误;返回 Map 仍按 dingId 去重。 */ private async getExistingRecordsByDingId( results: DingTalkAttendanceResult[], ): Promise> { - const dingIds = results.map((r) => r.checkId).filter(Boolean); + const dingIds = [...new Set(results.map((r) => r.checkId).filter(Boolean))]; if (dingIds.length === 0) return new Map(); - const existing = await this.dingRawRepo.find({ - where: { dingId: In(dingIds) }, - }); - return new Map(existing.map((entity) => [entity.dingId, entity])); + const chunks: string[][] = []; + for (let i = 0; i < dingIds.length; i += MAX_DING_IDS_PER_QUERY) { + chunks.push(dingIds.slice(i, i + MAX_DING_IDS_PER_QUERY)); + } + const chunkResults = await Promise.all( + chunks.map((chunk) => + this.dingRawRepo.find({ + where: { dingId: In(chunk) }, + }), + ), + ); + return new Map(chunkResults.flat().map((entity) => [entity.dingId, entity])); } private async refreshDuplicatePunchMetadata( @@ -288,11 +384,15 @@ export class AttendanceImportService { /** * Map a DingTalk API result to a DingAttendanceRaw entity. + * 学生姓名来自调用前批量预取的 map,避免逐条触发两次查询(N+1)。 */ - private async mapToEntity(r: DingTalkAttendanceResult): Promise { + private mapToEntity( + r: DingTalkAttendanceResult, + studentNameByDingUserId: Map, + ): DingAttendanceRaw { const entity = new DingAttendanceRaw(); entity.dingUserId = r.userId; - entity.userName = r.userName || await this.resolveStudentName(r.userId); + entity.userName = r.userName || studentNameByDingUserId.get(r.userId) || ''; entity.attendanceDate = r.workDate; entity.dingId = r.checkId; entity.attendanceType = r.checkType || 'OnDuty'; @@ -302,7 +402,9 @@ export class AttendanceImportService { entity.punchDeviceName = r.deviceName || null; entity.punchDeviceId = r.deviceId || null; - if (r.actualCheckTime) { + // checkType 白名单校验:非法类型无法确定是签到还是签退,不写时间, + // 避免污染 checkOutTime/checkInTime(记录本身仍保留并计入导入)。 + if (r.actualCheckTime && ATTENDANCE_CHECK_TYPE_WHITELIST.includes(r.checkType)) { const dt = new Date(r.actualCheckTime); if (!isNaN(dt.getTime())) { if (r.checkType === 'OnDuty') { @@ -318,13 +420,64 @@ export class AttendanceImportService { return entity; } - private async resolveStudentName(dingUserId: string): Promise { - const mapping = await this.studentDingMappingRepo.findOne({ - where: { dingUserId }, + /** + * 批量预取 dingUserId → 学生姓名:先一次性查 StudentDingMapping, + * 再一次性查 Student,最后组装成 Map。避免 mapToEntity 逐条两次查询(N+1)。 + */ + private async buildStudentNameByDingUserId( + results: DingTalkAttendanceResult[], + ): Promise> { + const dingUserIds = [ + ...new Set( + results + .filter((record) => !record.userName) + .map((record) => record.userId) + .filter(Boolean), + ), + ]; + if (dingUserIds.length === 0) return new Map(); + + const mappings = await this.studentDingMappingRepo.find({ + where: { dingUserId: In(dingUserIds) }, }); - if (!mapping) return ''; - const student = await this.studentRepo.findOne({ where: { id: mapping.studentId } }); - return student?.name || ''; + if (mappings.length === 0) return new Map(); + + const studentIds = [...new Set(mappings.map((mapping) => mapping.studentId))]; + const students = await this.studentRepo.find({ + where: { id: In(studentIds) }, + }); + const studentNameById = new Map( + students.map((student) => [student.id, student.name]), + ); + const nameByDingUserId = new Map(); + for (const mapping of mappings) { + const name = studentNameById.get(mapping.studentId); + if (name) nameByDingUserId.set(mapping.dingUserId, name); + } + return nameByDingUserId; + } + + /** + * 以固定并发上限执行异步 mapper,返回结果保持输入顺序。 + */ + private async mapWithConcurrency( + items: T[], + limit: number, + mapper: (item: T) => Promise, + ): Promise { + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.min(limit, items.length); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(items[index]); + } + }), + ); + return results; } /** diff --git a/apps/server/src/attendance/attendance-report.service.spec.ts b/apps/server/src/attendance/attendance-report.service.spec.ts new file mode 100644 index 00000000..be2239d4 --- /dev/null +++ b/apps/server/src/attendance/attendance-report.service.spec.ts @@ -0,0 +1,76 @@ +import { AttendanceReportService } from './attendance-report.service'; + +describe('AttendanceReportService.getAlerts', () => { + function createService(records: unknown[]) { + const attendanceRepo = { + createQueryBuilder: jest.fn(() => { + const qb: any = { + leftJoinAndSelect: jest.fn(() => qb), + where: jest.fn(() => qb), + andWhere: jest.fn(() => qb), + orderBy: jest.fn(() => qb), + addOrderBy: jest.fn(() => qb), + getMany: jest.fn().mockResolvedValue(records), + }; + return qb; + }), + }; + return new AttendanceReportService(attendanceRepo as never, {} as never); + } + + const absent = (studentId: number, date: string) => ({ + studentId, + status: 'absent', + attendanceDate: date, + student: { name: `S${studentId}` }, + class: { name: '一班' }, + }); + + it('returns the most recent run when consecutive runs have equal length', async () => { + const service = createService([ + absent(1, '2026-07-01'), + absent(1, '2026-07-02'), + absent(1, '2026-07-05'), + absent(1, '2026-07-06'), + ]); + + const alerts = await service.getAlerts(30, 2); + + expect(alerts).toHaveLength(1); + expect(alerts[0]).toMatchObject({ + studentId: 1, + studentName: 'S1', + className: '一班', + type: '缺勤', + count: 2, + lastDate: '2026-07-06', + }); + }); + + it('uses the latest date of the longest run as lastDate', async () => { + const service = createService([ + absent(2, '2026-07-01'), + absent(2, '2026-07-02'), + absent(2, '2026-07-03'), + absent(2, '2026-07-06'), + absent(2, '2026-07-07'), + ]); + + const alerts = await service.getAlerts(30, 2); + + expect(alerts).toHaveLength(1); + expect(alerts[0]).toMatchObject({ count: 3, lastDate: '2026-07-03' }); + }); + + it('does not merge non-consecutive absences into one run', async () => { + const service = createService([ + absent(3, '2026-07-01'), + absent(3, '2026-07-03'), + absent(3, '2026-07-05'), + ]); + + const alerts = await service.getAlerts(30, 3); + + expect(alerts).toHaveLength(0); + }); +}); diff --git a/apps/server/src/attendance/attendance-report.service.ts b/apps/server/src/attendance/attendance-report.service.ts index 72108e31..e9e19d2a 100644 --- a/apps/server/src/attendance/attendance-report.service.ts +++ b/apps/server/src/attendance/attendance-report.service.ts @@ -171,27 +171,72 @@ export class AttendanceReportService { lastDate: string; }> = []; - let current: (typeof alerts)[0] | null = null; + // 按「学生 + 状态」聚合记录日期,再做真实的连续日判断: + // 仅按状态/条数统计会把不连续的日子误算成连续缺勤/迟到。 + const grouped = new Map< + string, + { + studentId: number; + studentName: string; + className: string; + status: string; + dates: Set; + } + >(); for (const r of records) { - const name = r.student?.name || ''; - const className = r.class?.name || ''; - const status = r.status === 'absent' ? '缺勤' : '迟到'; - if (current && current.studentId === r.studentId && current.type === status) { - current.count++; - if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate; - } else { - if (current && current.count >= threshold) alerts.push({ ...current }); - current = { + const key = `${r.studentId}|${r.status}`; + let group = grouped.get(key); + if (!group) { + group = { studentId: r.studentId, - studentName: name, - className, - type: status, - count: 1, - lastDate: r.attendanceDate, + studentName: r.student?.name || '', + className: r.class?.name || '', + status: r.status, + dates: new Set(), }; + grouped.set(key, group); + } + group.dates.add(r.attendanceDate); + } + + for (const group of grouped.values()) { + // 按日期倒序聚合:长度相同的连续段取「最近一段」(先遇到者胜出), + // lastDate 指向该段内最新日期,保持重构前的原语义。 + const sortedDates = [...group.dates].sort((a, b) => (a < b ? 1 : -1)); + let bestRun = 0; + let bestRunLastDate = ''; + let run = 1; + let runLastDate = sortedDates[0] || ''; + for (let i = 1; i <= sortedDates.length; i++) { + const date = sortedDates[i]; + if ( + i < sortedDates.length && + dayjs.utc(sortedDates[i - 1]).diff(dayjs.utc(date), 'day') === 1 + ) { + // 倒序连续:runLastDate 保持该段内最早遇到(即最新)的日期 + run++; + } else { + if (run > bestRun) { + bestRun = run; + bestRunLastDate = runLastDate; + } + if (i < sortedDates.length) { + run = 1; + runLastDate = date; + } + } + } + if (bestRun >= threshold) { + alerts.push({ + studentId: group.studentId, + studentName: group.studentName, + className: group.className, + type: group.status === 'absent' ? '缺勤' : '迟到', + count: bestRun, + lastDate: bestRunLastDate, + }); } } - if (current && current.count >= threshold) alerts.push(current); return alerts; } } diff --git a/apps/server/src/attendance/attendance.boundaries.spec.ts b/apps/server/src/attendance/attendance.boundaries.spec.ts index 1dd35a7e..7553b62c 100644 --- a/apps/server/src/attendance/attendance.boundaries.spec.ts +++ b/apps/server/src/attendance/attendance.boundaries.spec.ts @@ -25,6 +25,22 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => { ...periodConfigRepoOverrides, }; + // saveAttendancePeriodConfigs/resetAttendancePeriodConfigs 现在在事务内写库: + // 事务 manager 的 clear/create/save 委托给同一个 periodConfigRepo mock, + // 保证原有断言(如 savedPeriods 捕获)仍然成立。 + const transactionManager = { + clear: jest.fn().mockImplementation(() => periodConfigRepo.clear()), + create: jest.fn().mockImplementation((_entity: unknown, data: unknown) => + Array.isArray(data) ? data : periodConfigRepo.create(data), + ), + save: jest.fn().mockImplementation((entities: unknown) => periodConfigRepo.save(entities)), + }; + const dataSource = { + transaction: jest.fn(async (cb: (manager: unknown) => Promise) => + cb(transactionManager), + ), + }; + return new AttendanceService( {} as never, // attendanceRepo {} as never, // dingRawRepo @@ -38,7 +54,7 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => { {} as never, // attendanceSessionRepo {} as never, // attendanceDeviceRepo periodConfigRepo as never, - {} as never, // dataSource + dataSource as never, ); } diff --git a/apps/server/src/bills/bills-generation.service.spec.ts b/apps/server/src/bills/bills-generation.service.spec.ts new file mode 100644 index 00000000..282090c1 --- /dev/null +++ b/apps/server/src/bills/bills-generation.service.spec.ts @@ -0,0 +1,441 @@ +import { BillsGenerationService } from './bills-generation.service'; +import type { GenerateBillsDto } from './dto/bill.dto'; +import { Bill, RoomExpense, PersonalExpense, Occupancy } from '../entities'; + +function mockQueryBuilder(results: T[]) { + return { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(results), + }; +} + +function createService(existingPeriodBills: Array> = []) { + const lockQb = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(existingPeriodBills), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const manager = { + createQueryBuilder: jest.fn().mockReturnValue(lockQb), + create: jest.fn((_entity: unknown, value: unknown) => value), + save: jest.fn(async (value: Record) => ({ id: 1, ...value })), + // advisory lock:默认首请求可拿到锁,RELEASE_LOCK 直接成功 + query: jest.fn(async (sql: string) => + sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }], + ), + }; + const dataSource = { transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)) }; + const roomExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + }; + const occRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + }; + const personalExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + }; + const service = new BillsGenerationService( + roomExpRepo as never, + personalExpRepo as never, + occRepo as never, + dataSource as never, + { debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never, + ); + return { service, dataSource, manager, lockQb, roomExpRepo, occRepo, personalExpRepo }; +} + +const PERIOD = { periodStart: '2026-06-01', periodEnd: '2026-06-30' } as GenerateBillsDto; + +describe('BillsGenerationService — 周期内重复生成并发防重', () => { + it('rejects duplicate generation inside the transaction with a locked re-check', async () => { + const { service, dataSource, manager, lockQb } = createService([ + { id: 1, periodStart: '2026-06-01', periodEnd: '2026-06-30' }, + ]); + + await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('账单已生成'); + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(lockQb.setLock).toHaveBeenCalledWith('pessimistic_write'); + expect(lockQb.where).toHaveBeenCalledWith('b.periodStart = :periodStart', { + periodStart: '2026-06-01', + }); + expect(lockQb.andWhere).toHaveBeenCalledWith('b.periodEnd = :periodEnd', { + periodEnd: '2026-06-30', + }); + expect(lockQb.getMany).toHaveBeenCalledTimes(1); + expect(manager.save).not.toHaveBeenCalled(); + }); + + it('still performs the locked re-check and generates bills when the period is free', async () => { + const { service, dataSource, manager, lockQb, roomExpRepo, occRepo } = createService(); + (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + expenseType: 'water', + amount: 100, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + } as RoomExpense, + ]), + ); + (occRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + studentId: 10, + stayType: 'short', + billingStartDate: '2026-06-01', + billingEndDate: '2026-06-30', + } as Occupancy, + ]), + ); + + const result = await service.generateBillsOnce(PERIOD); + + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(lockQb.setLock).toHaveBeenCalledWith('pessimistic_write'); + expect(lockQb.getMany).toHaveBeenCalledTimes(1); + expect(manager.save).toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + message: '成功生成 1 条账单', + count: 1, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + }), + ); + }); + + it('loads occupancies for all rooms in one batched query (no per-room N+1)', async () => { + const { service, manager, occRepo, roomExpRepo } = createService(); + (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + expenseType: 'water', + amount: 100, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + } as RoomExpense, + { + id: 2, + roomId: 2, + expenseType: 'water', + amount: 200, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + } as RoomExpense, + ]), + ); + const batchQb = mockQueryBuilder([ + { + id: 1, + roomId: 1, + studentId: 10, + stayType: 'short', + billingStartDate: '2026-06-01', + billingEndDate: '2026-06-30', + } as Occupancy, + { + id: 2, + roomId: 2, + studentId: 11, + stayType: 'short', + billingStartDate: '2026-06-01', + billingEndDate: '2026-06-30', + } as Occupancy, + ]); + // 第 1 次:roomIds 聚合查询(长租 active 入住);第 2 次:全房间批量入住查询 + (occRepo.createQueryBuilder as jest.Mock) + .mockReturnValueOnce(mockQueryBuilder([])) + .mockReturnValueOnce(batchQb); + + const result = await service.generateBillsOnce(PERIOD); + + // 入住查询只发两次:roomIds 聚合 1 次 + roomIds 批量查询 1 次(不再按房间循环 N+1) + expect(occRepo.createQueryBuilder).toHaveBeenCalledTimes(2); + expect(batchQb.where).toHaveBeenCalledWith('o.roomId IN (:...roomIds)', { + roomIds: [1, 2], + }); + expect(batchQb.andWhere).toHaveBeenCalledWith('o.billingStartDate <= :periodEnd', { + periodEnd: '2026-06-30', + }); + expect(batchQb.andWhere).toHaveBeenCalledWith( + '(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', + { periodStart: '2026-06-01' }, + ); + // 内存分组后仍按原逻辑为每个房间的学生生成账单 + expect(manager.save).toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + message: '成功生成 2 条账单', + count: 2, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + }), + ); + }); + + it('only one of two concurrent requests succeeds; the second sees committed bills and rejects', async () => { + const committed: Array> = []; + const lockQb = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockImplementation(async () => committed), + }; + const manager = { + createQueryBuilder: jest.fn().mockReturnValue(lockQb), + create: jest.fn((_entity: unknown, value: unknown) => value), + save: jest.fn(async (value: Record) => { + if ('studentId' in value && 'periodStart' in value) { + const saved = { id: committed.length + 1, ...value }; + committed.push(saved); + return saved; + } + return { id: 1, ...value }; + }), + query: jest.fn(async (sql: string) => + sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }], + ), + }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)), + }; + const roomExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + expenseType: 'water', + amount: 100, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + } as RoomExpense, + ]), + ), + }; + const occRepo = { + createQueryBuilder: jest.fn().mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + studentId: 10, + stayType: 'short', + billingStartDate: '2026-06-01', + billingEndDate: '2026-06-30', + } as Occupancy, + ]), + ), + }; + const personalExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + }; + const service = new BillsGenerationService( + roomExpRepo as never, + personalExpRepo as never, + occRepo as never, + dataSource as never, + { debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never, + ); + + const first = await service.generateBillsOnce(PERIOD); + expect(first.count).toBe(1); + expect(committed).toHaveLength(1); + + // 第二个并发请求在锁释放后能看到第一个请求已提交的账单,直接抛「账单已生成」 + await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('账单已生成'); + expect(committed).toHaveLength(1); + }); + + it('throws ConflictException when the advisory lock cannot be acquired in time', async () => { + const { service, manager } = createService(); + (manager.query as jest.Mock).mockResolvedValue([{ acquired: 0 }]); + + await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('正在生成'); + expect(manager.query).toHaveBeenCalledWith( + "SELECT GET_LOCK('gongxue:bills-gen:2026-06-01-2026-06-30', 5) AS acquired", + ); + // 未拿到锁:不进入重复检查与写入 + expect(manager.save).not.toHaveBeenCalled(); + }); + + it('only one of two truly concurrent first-time requests succeeds (advisory lock)', async () => { + const committed: Array> = []; + let lockHeld = false; + let markSecondArrived!: () => void; + const secondArrived = new Promise((resolve) => { + markSecondArrived = resolve; + }); + let releaseFirstSave!: () => void; + + const lockQb = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockImplementation(async () => committed), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const manager = { + createQueryBuilder: jest.fn().mockReturnValue(lockQb), + create: jest.fn((_entity: unknown, value: unknown) => value), + save: jest.fn(async (value: Record) => { + if ('studentId' in value && 'periodStart' in value) { + // 第一个请求已持有锁并开始写账单,此时放第二个并发请求进来抢同一把锁 + markSecondArrived(); + await new Promise((resolve) => { + releaseFirstSave = resolve; + }); + const saved = { id: committed.length + 1, ...value }; + committed.push(saved); + return saved; + } + return { id: 1, ...value }; + }), + query: jest.fn(async (sql: string) => { + if (sql.includes('GET_LOCK')) { + if (lockHeld) return [{ acquired: 0 }]; + lockHeld = true; + return [{ acquired: 1 }]; + } + if (sql.includes('RELEASE_LOCK')) { + lockHeld = false; + return [{ released: 1 }]; + } + return []; + }), + }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)), + }; + const roomExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + expenseType: 'water', + amount: 100, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + } as RoomExpense, + ]), + ), + }; + const occRepo = { + createQueryBuilder: jest.fn().mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + studentId: 10, + stayType: 'short', + billingStartDate: '2026-06-01', + billingEndDate: '2026-06-30', + } as Occupancy, + ]), + ), + }; + const personalExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + }; + const service = new BillsGenerationService( + roomExpRepo as never, + personalExpRepo as never, + occRepo as never, + dataSource as never, + { debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never, + ); + + const firstPromise = service.generateBillsOnce(PERIOD); + await secondArrived; + + // 第二个并发请求在第一个请求持有锁期间尝试获取同一把锁 → 超时/被占用 → ConflictException + await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('正在生成'); + expect(committed).toHaveLength(0); + + releaseFirstSave(); + const first = await firstPromise; + expect(first.count).toBe(1); + expect(committed).toHaveLength(1); + expect(lockHeld).toBe(false); + }); + + it('saves all BillItems of a bill in one batched save', async () => { + const { service, manager, personalExpRepo } = createService(); + (personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 9, + studentId: 10, + roomId: 1, + expenseType: 'meal', + description: '三餐', + amount: 50, + expenseDate: '2026-06-05', + status: 'active', + } as PersonalExpense, + ]), + ); + + await service.generateBillsOnce(PERIOD); + + const arraySaves = (manager.save as jest.Mock).mock.calls.filter( + ([value]) => Array.isArray(value), + ); + expect(arraySaves).toHaveLength(1); + expect(arraySaves[0][0]).toHaveLength(1); + expect(arraySaves[0][0][0]).toEqual( + expect.objectContaining({ + billId: 1, + personalExpenseId: 9, + expenseType: 'meal', + studentAmount: 50, + }), + ); + }); + + it('rounds personalAmount to two decimals before saving', async () => { + const { service, manager, personalExpRepo } = createService(); + (personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 10, + studentId: 10, + roomId: 1, + expenseType: 'meal', + description: '三餐', + amount: 0.30000000000000004, + expenseDate: '2026-06-05', + status: 'active', + } as PersonalExpense, + ]), + ); + + await service.generateBillsOnce(PERIOD); + + const billSave = (manager.save as jest.Mock).mock.calls.find( + ([value]) => !Array.isArray(value) && value?.studentId === 10, + ); + expect(billSave).toBeDefined(); + expect(billSave![0]).toEqual( + expect.objectContaining({ + personalAmount: 0.3, + totalAmount: 0.3, + }), + ); + }); +}); diff --git a/apps/server/src/bills/bills-generation.service.ts b/apps/server/src/bills/bills-generation.service.ts index 5861fd2e..20696448 100644 --- a/apps/server/src/bills/bills-generation.service.ts +++ b/apps/server/src/bills/bills-generation.service.ts @@ -1,20 +1,44 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; -import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room } from '../entities'; +import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy } from '../entities'; import { WalletsService } from '../wallets/wallets.service'; import type { GenerateBillsDto } from './dto/bill.dto'; import dayjs from '../common/dayjs'; +/** 把 TypeORM 可能 hydrate 成 Date 的 date-only 字段归一化为 YYYY-MM-DD 字符串。 */ +function toDateOnly(value: Date | string | null | undefined): string { + if (value == null) return ''; + if (value instanceof Date) { + const y = value.getFullYear(); + const m = String(value.getMonth() + 1).padStart(2, '0'); + const d = String(value.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + return String(value); +} + +/** YYYY-MM-DD 字符串(字典序即时间序)取较大/较小者。 */ +function maxDateOnly(a: string, b: string): string { + return a > b ? a : b; +} +function minDateOnly(a: string, b: string): string { + return a < b ? a : b; +} + +/** YYYY-MM-DD 字符串相差的天数(a 晚于 b 返回负值)。 */ +function daysBetweenDateOnly(a: string, b: string): number { + const [ay, am, ad] = a.split('-').map(Number); + const [by, bm, bd] = b.split('-').map(Number); + return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86_400_000); +} + @Injectable() export class BillsGenerationService { constructor( - @InjectRepository(Bill) private billRepo: Repository, - @InjectRepository(BillItem) private itemRepo: Repository, @InjectRepository(RoomExpense) private roomExpRepo: Repository, @InjectRepository(PersonalExpense) private personalExpRepo: Repository, @InjectRepository(Occupancy) private occRepo: Repository, - @InjectRepository(Room) private roomRepo: Repository, private dataSource: DataSource, private walletsService: WalletsService, ) {} @@ -26,14 +50,6 @@ export class BillsGenerationService { if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { throw new BadRequestException('账单周期无效,结束日期不能早于开始日期'); } - const pStart = new Date(`${periodStart}T00:00:00Z`); - const pEnd = new Date(`${periodEnd}T00:00:00Z`); - const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } }); - if (existingBills.length > 0) { - throw new BadRequestException( - `${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`, - ); - } const roomExpenses = await this.roomExpRepo .createQueryBuilder('e') .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { @@ -42,7 +58,16 @@ export class BillsGenerationService { }) .andWhere('e.status = :status', { status: 'active' }) .getMany(); - const longTermOccupancies: Occupancy[] = []; + // 长租入住:即使本周期没有费用记录,也按长租计费纳入开票范围 + const longTermOccupancies: Occupancy[] = await this.occRepo + .createQueryBuilder('o') + .leftJoinAndSelect('o.student', 'student') + .leftJoinAndSelect('o.room', 'room') + .where('o.stayType = :stayType', { stayType: 'long' }) + .andWhere('o.status = :status', { status: 'active' }) + .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) + .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) + .getMany(); const roomExpMap = new Map(); for (const expense of roomExpenses) { const expenses = roomExpMap.get(expense.roomId) || []; @@ -60,16 +85,36 @@ export class BillsGenerationService { { shared: number; items: Array> } >(); + // 一次性按 roomIds In(...) 查询本周期内全部在住/曾住的入住记录,内存按 roomId 分组, + // 避免对每个房间循环 createQueryBuilder(...).getMany()(N+1)。 + // 有意不按 o.status 过滤:这里要查这些房间在本周期内全部在住/曾住的入住记录来分摊费用, + // 退宿后归档的入住记录只要 billingStartDate/billingEndDate 覆盖本周期仍应参与分摊 + // (与 occupancy-operations.getRoomOccupanciesInPeriod 的计费语义一致)。 + // status='active' 过滤只用于上面 roomIds 聚合(决定哪些房间进入开票范围),不用于分摊查询。 + const roomIdList = [...roomIds]; + const allOccupancies: Occupancy[] = + roomIdList.length > 0 + ? await this.occRepo + .createQueryBuilder('o') + .leftJoinAndSelect('o.student', 'student') + .leftJoinAndSelect('o.room', 'room') + .where('o.roomId IN (:...roomIds)', { roomIds: roomIdList }) + .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) + .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { + periodStart, + }) + .getMany() + : []; + const occupanciesByRoom = new Map(); + for (const occupancy of allOccupancies) { + const list = occupanciesByRoom.get(occupancy.roomId) || []; + list.push(occupancy); + occupanciesByRoom.set(occupancy.roomId, list); + } + for (const roomId of roomIds) { const expenses = roomExpMap.get(roomId) || []; - const occupancies = await this.occRepo - .createQueryBuilder('o') - .leftJoinAndSelect('o.student', 'student') - .leftJoinAndSelect('o.room', 'room') - .where('o.roomId = :roomId', { roomId }) - .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) - .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) - .getMany(); + const occupancies = occupanciesByRoom.get(roomId) || []; const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long'); const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long'); @@ -95,21 +140,21 @@ export class BillsGenerationService { studentBillData.set(occupancy.studentId, data); } + // 日期统一用 YYYY-MM-DD 字符串比较/运算,避免 TypeORM 把 date 列 hydrate 成 Date 后比较不可靠 const studentDays = shortTermOccs.map((occupancy) => { - const start = new Date( - Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime()), - ); - const end = occupancy.billingEndDate - ? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime())) - : pEnd; - const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1); + const startStr = toDateOnly(occupancy.billingStartDate); + const endStr = toDateOnly(occupancy.billingEndDate) || periodEnd; + if (!startStr) return { studentId: occupancy.studentId, days: 0 }; + const start = maxDateOnly(startStr, periodStart); + const end = minDateOnly(endStr, periodEnd); + const days = end < start ? 0 : daysBetweenDateOnly(start, end) + 1; return { studentId: occupancy.studentId, days }; }); const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0); if (totalDays === 0) continue; + const eligibleDays = studentDays.filter((entry) => entry.days > 0); for (const expense of expenses) { - const eligibleDays = studentDays.filter((entry) => entry.days > 0); const expenseTotal = Number(Number(expense.amount).toFixed(2)); let allocated = 0; for (const [index, entry] of eligibleDays.entries()) { @@ -167,44 +212,81 @@ export class BillsGenerationService { const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]); const bills = await this.dataSource.transaction(async (manager) => { - const generated: Bill[] = []; - for (const studentId of allStudentIds) { - const shared = studentBillData.get(studentId)?.shared || 0; - const personal = personalMap.get(studentId) || 0; - const total = Number((shared + personal).toFixed(2)); - let bill = await manager.save( - manager.create(Bill, { - studentId, - periodStart, - periodEnd, - sharedAmount: Number(shared.toFixed(2)), - personalAmount: personal, - totalAmount: total, - source: 'batch', - paidAmount: 0, - outstandingAmount: total, - status: 'unpaid', - }), + // 首次生成的并发防护:FOR UPDATE 对不存在的行不加锁,两个并发请求都能通过检查; + // 这里在事务内先拿按周期 key 的 MySQL advisory lock(GET_LOCK 对不存在的 key 同样生效), + // 失败/超时直接抛 ConflictException,重复检查放进锁内。financialOperations.run 只覆盖 + // 带 operationId 的幂等场景,无 operationId 的并发首次生成靠这把锁兜底。 + const lockName = `gongxue:bills-gen:${periodStart}-${periodEnd}`; + const lockRows = (await manager.query( + `SELECT GET_LOCK('${lockName}', 5) AS acquired`, + )) as unknown as Array<{ acquired?: unknown }> | undefined; + if (Number(lockRows?.[0]?.acquired ?? 0) !== 1) { + throw new ConflictException( + `${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单正在生成中,请勿重复提交`, ); - const items = [ - ...(studentBillData.get(studentId)?.items || []), - ...(personalItems.get(studentId) || []), - ]; - for (const item of items) - await manager.save(manager.create(BillItem, { ...item, billId: bill.id })); - const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId); - if (includedPersonal.length) { - await manager - .createQueryBuilder() - .update(PersonalExpense) - .set({ billId: bill.id }) - .where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) }) - .execute(); - } - bill = await this.walletsService.debitBill(manager, bill); - generated.push(bill); } - return generated; + try { + // 周期内重复生成检查移进事务内,并对同一 (periodStart, periodEnd) 加锁复查: + // 并发请求会在锁释放后看到已提交的账单并抛「账单已生成」,保证只有一个请求成功。 + const existingBills = await manager + .createQueryBuilder(Bill, 'b') + .setLock('pessimistic_write') + .where('b.periodStart = :periodStart', { periodStart }) + .andWhere('b.periodEnd = :periodEnd', { periodEnd }) + .getMany(); + if (existingBills.length > 0) { + throw new BadRequestException( + `${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`, + ); + } + const generated: Bill[] = []; + for (const studentId of allStudentIds) { + const shared = studentBillData.get(studentId)?.shared || 0; + const personal = personalMap.get(studentId) || 0; + const total = Number((shared + personal).toFixed(2)); + let bill = await manager.save( + manager.create(Bill, { + studentId, + periodStart, + periodEnd, + sharedAmount: Number(shared.toFixed(2)), + personalAmount: Number(personal.toFixed(2)), + totalAmount: total, + source: 'batch', + paidAmount: 0, + outstandingAmount: total, + status: 'unpaid', + }), + ); + const items = [ + ...(studentBillData.get(studentId)?.items || []), + ...(personalItems.get(studentId) || []), + ]; + // 逐条 save 改为批量一次保存,减少往返 + if (items.length > 0) { + await manager.save( + manager.create( + BillItem, + items.map((item) => ({ ...item, billId: bill.id })), + ), + ); + } + const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId); + if (includedPersonal.length) { + await manager + .createQueryBuilder() + .update(PersonalExpense) + .set({ billId: bill.id }) + .where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) }) + .execute(); + } + bill = await this.walletsService.debitBill(manager, bill); + generated.push(bill); + } + return generated; + } finally { + await manager.query(`SELECT RELEASE_LOCK('${lockName}')`); + } }); return { message: `成功生成 ${bills.length} 条账单`, @@ -222,12 +304,10 @@ export class BillsGenerationService { periodEnd: string, monthlyRate: number, ) { - const activeStart = - occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart; - const activeEnd = - occupancy.billingEndDate && occupancy.billingEndDate < periodEnd - ? occupancy.billingEndDate - : periodEnd; + const startStr = toDateOnly(occupancy.billingStartDate); + const endStr = toDateOnly(occupancy.billingEndDate) || periodEnd; + const activeStart = startStr > periodStart ? startStr : periodStart; + const activeEnd = endStr && endStr < periodEnd ? endStr : periodEnd; if (activeEnd < activeStart || monthlyRate <= 0) return 0; const [startYear, startMonth] = activeStart.split('-').map(Number); const [endYear, endMonth] = activeEnd.split('-').map(Number); diff --git a/apps/server/src/bills/bills.controller.spec.ts b/apps/server/src/bills/bills.controller.spec.ts new file mode 100644 index 00000000..00fd0581 --- /dev/null +++ b/apps/server/src/bills/bills.controller.spec.ts @@ -0,0 +1,196 @@ +import { BillsController } from './bills.controller'; +import { NotificationType } from '../entities/notification.entity'; + +describe('BillsController — 通知批量预取学生', () => { + function createController(options: { + bills?: Array>; + students?: Array>; + } = {}) { + const { bills = [], students = [] } = options; + const service = { + generateBills: jest.fn().mockResolvedValue({ + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + count: bills.length, + bills, + }), + batchUpdateStatus: jest.fn().mockResolvedValue({}), + }; + const studentRepo = { + find: jest.fn().mockResolvedValue(students), + findOne: jest.fn(), + }; + const billRepo = { findBy: jest.fn().mockResolvedValue(bills) }; + const notificationsService = { + create: jest.fn().mockResolvedValue(undefined), + }; + const logService = { log: jest.fn().mockResolvedValue(undefined) }; + const controller = new BillsController( + service as never, + {} as never, + logService as never, + notificationsService as never, + studentRepo as never, + billRepo as never, + ); + return { controller, service, studentRepo, billRepo, notificationsService }; + } + + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + + it('batches student lookup before sending bill_generated notifications', async () => { + const { controller, studentRepo, notificationsService } = createController({ + bills: [ + { id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' }, + { id: 2, studentId: 10, totalAmount: 80, status: 'unpaid' }, + { id: 3, studentId: 11, totalAmount: 50, status: 'unpaid' }, + ], + students: [ + { id: 10, userId: 100 }, + { id: 11, userId: null }, + ], + }); + + await controller.generateBills({} as never, req as never); + + expect(studentRepo.find).toHaveBeenCalledTimes(1); + const where = (studentRepo.find as jest.Mock).mock.calls[0][0].where; + expect(where.id.value).toEqual([10, 11]); + expect(studentRepo.findOne).not.toHaveBeenCalled(); + expect(notificationsService.create).toHaveBeenCalledTimes(2); + expect(notificationsService.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + recipientIds: [100], + type: NotificationType.BILL_GENERATED, + title: '新账单', + }), + ); + }); + + it('notifies only paid bills in batch status update with a single student query', async () => { + const { controller, studentRepo, notificationsService, billRepo } = createController({ + bills: [ + { id: 1, studentId: 10, totalAmount: 100, status: 'paid' }, + { id: 2, studentId: 11, totalAmount: 50, status: 'unpaid' }, + { id: 3, studentId: 10, totalAmount: 80, status: 'paid' }, + ], + students: [{ id: 10, userId: 100 }], + }); + // 更新前状态:1/2/3 都未付款;更新后 1/3 变为 paid → 只通知 1/3(幂等重确认不重复通知) + billRepo.findBy.mockResolvedValueOnce([ + { id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' }, + { id: 2, studentId: 11, totalAmount: 50, status: 'unpaid' }, + { id: 3, studentId: 10, totalAmount: 80, status: 'unpaid' }, + ]); + + await controller.batchUpdateStatus({ ids: [1, 2, 3], status: 'paid' }, req as never); + + expect(studentRepo.find).toHaveBeenCalledTimes(1); + expect(notificationsService.create).toHaveBeenCalledTimes(2); + expect(notificationsService.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + recipientIds: [100], + type: NotificationType.BILL_PAID, + title: '账单已确认', + content: expect.stringContaining('#1'), + }), + ); + expect(notificationsService.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ content: expect.stringContaining('#3') }), + ); + }); + + it('a notification failure does not stop notifications for other bills', async () => { + let call = 0; + const notificationsService = { + create: jest.fn().mockImplementation(() => { + call += 1; + if (call === 1) return Promise.reject(new Error('notify down')); + return Promise.resolve(undefined); + }), + }; + const studentRepo = { + find: jest.fn().mockResolvedValue([{ id: 10, userId: 100 }]), + findOne: jest.fn(), + }; + const billRepo = { + findBy: jest.fn() + .mockResolvedValueOnce([ + { id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' }, + { id: 2, studentId: 10, totalAmount: 50, status: 'unpaid' }, + ]) + .mockResolvedValue([ + { id: 1, studentId: 10, totalAmount: 100, status: 'paid' }, + { id: 2, studentId: 10, totalAmount: 50, status: 'paid' }, + ]), + }; + const service = { + batchUpdateStatus: jest.fn().mockResolvedValue({}), + generateBills: jest.fn(), + }; + const controller = new BillsController( + service as never, + {} as never, + { log: jest.fn().mockResolvedValue(undefined) } as never, + notificationsService as never, + studentRepo as never, + billRepo as never, + ); + + await controller.batchUpdateStatus({ ids: [1, 2], status: 'paid' }, req as never); + + expect(notificationsService.create).toHaveBeenCalledTimes(2); + }); + + it('logs notification failures instead of swallowing them silently', async () => { + let call = 0; + const notificationsService = { + create: jest.fn().mockImplementation(() => { + call += 1; + if (call === 1) return Promise.reject(new Error('notify down')); + return Promise.resolve(undefined); + }), + }; + const studentRepo = { + find: jest.fn().mockResolvedValue([{ id: 10, userId: 100 }]), + findOne: jest.fn(), + }; + const billRepo = { + findBy: jest.fn() + .mockResolvedValueOnce([ + { id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' }, + { id: 2, studentId: 10, totalAmount: 50, status: 'unpaid' }, + ]) + .mockResolvedValue([ + { id: 1, studentId: 10, totalAmount: 100, status: 'paid' }, + { id: 2, studentId: 10, totalAmount: 50, status: 'paid' }, + ]), + }; + const service = { + batchUpdateStatus: jest.fn().mockResolvedValue({}), + generateBills: jest.fn(), + }; + const controller = new BillsController( + service as never, + {} as never, + { log: jest.fn().mockResolvedValue(undefined) } as never, + notificationsService as never, + studentRepo as never, + billRepo as never, + ); + const warnSpy = jest.spyOn((controller as any).logger, 'warn').mockImplementation(() => undefined); + + try { + await controller.batchUpdateStatus({ ids: [1, 2], status: 'paid' }, req as never); + + // Promise.allSettled:失败通知不阻断其他通知,且失败被记录到日志 + expect(notificationsService.create).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('notify down')); + } finally { + warnSpy.mockRestore(); + } + }); +}); diff --git a/apps/server/src/bills/bills.controller.ts b/apps/server/src/bills/bills.controller.ts index 746cdc73..f8e748bb 100644 --- a/apps/server/src/bills/bills.controller.ts +++ b/apps/server/src/bills/bills.controller.ts @@ -12,6 +12,7 @@ import { Res, Req, ParseIntPipe, + Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; @@ -21,7 +22,13 @@ import { NotificationType } from '../entities/notification.entity'; import { Student } from '../entities/student.entity'; import { Bill } from '../entities/bill.entity'; import { BillsExportService } from './bills-export.service'; -import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; +import { + BatchIdsBodyDto, + CancelBillDto, + GenerateBillsDto, + UpdateBillStatusDto, +} from './dto/bill.dto'; +import { BatchIdsDto } from '../common/batch-ids.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; @@ -38,6 +45,8 @@ interface AuthenticatedRequest { @UseGuards(JwtAuthGuard) @Controller('bills') export class BillsController { + private readonly logger = new Logger(BillsController.name); + constructor( private service: BillsService, private exportService: BillsExportService, @@ -56,18 +65,20 @@ export class BillsController { }); // Send bill_generated notifications try { - for (const bill of result.bills) { - const student = await this.studentRepo.findOne({ where: { id: bill.studentId } }); - if (student?.userId) { - void this.notificationsService.create({ - recipientIds: [student.userId], - type: NotificationType.BILL_GENERATED, - title: '新账单', - content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`, - }); - } - } - } catch (_) { /* don't block response */ } + await this.sendPaidNotifications( + this.studentRepo, + this.notificationsService, + result.bills, + (bill) => ({ + type: NotificationType.BILL_GENERATED, + title: '新账单', + content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`, + }), + ); + } catch (err) { + // 预取/通知失败不影响主流程,但记录日志避免静默吞错 + this.logger.warn(`账单生成通知发送失败: ${String(err)}`); + } return result; } @@ -87,113 +98,6 @@ export class BillsController { }); } - @Get(':id') - @RequirePermission('bill:view') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.service.findOne(id); - } - - @Put(':id/status') - @RequirePermission('bill:confirm') - async updateStatus( - @Param('id', ParseIntPipe) id: number, - @Body() dto: UpdateBillStatusDto, - @Request() req: AuthenticatedRequest, - ) { - const result = await this.service.updateStatus(id, dto); - await logAudit(this.logService, req, { - module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill', - }); - // Send bill_paid notification - try { - const student = await this.studentRepo.findOne({ where: { id: result.studentId } }); - if (student?.userId) { - void this.notificationsService.create({ - recipientIds: [student.userId], - type: NotificationType.BILL_PAID, - title: '账单已确认', - content: `账单 #${result.id} 已确认收款,金额: ¥${result.totalAmount}`, - }); - } - } catch (_) { /* don't block response */ } - return result; - } - - @Put('batch/status') - @RequirePermission('bill:confirm') - async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: AuthenticatedRequest) { - const result = await this.service.batchUpdateStatus(body.ids, body.status); - await logAudit(this.logService, req, { - module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`, - }); - // Send bill_paid notifications (batch) - try { - const bills = await this.billRepo.findBy({ id: In(body.ids) }); - for (const bill of bills) { - const student = await this.studentRepo.findOne({ where: { id: bill.studentId } }); - if (student?.userId) { - void this.notificationsService.create({ - recipientIds: [student.userId], - type: NotificationType.BILL_PAID, - title: '账单已确认', - content: `账单 #${bill.id} 已确认收款,金额: ¥${bill.totalAmount}`, - }); - } - } - } catch (_) { /* don't block response */ } - return result; - } - - @Post(':id/cancel') - @RequirePermission('bill:delete') - async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: AuthenticatedRequest) { - const result = await this.service.cancel(id, dto, req.user?.id); - await logAudit(this.logService, req, { - module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason, - }); - return result; - } - - @Delete(':id') - @RequirePermission('bill:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const result = await this.service.remove(id); - await logAudit(this.logService, req, { - module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill', - }); - return result; - } - - @Delete(':id/permanent') - @RequirePermission('bill:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const result = await this.service.purge(id); - await logAudit(this.logService, req, { - module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复', - }); - return result; - } - - @Post('batch-permanent-delete') - @RequirePermission('bill:purge') - async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { - const result = await this.service.batchPurge(body.ids || []); - await logAudit(this.logService, req, { - module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`, - }); - return result; - } - - @Post('batch/delete') - @RequirePermission('bill:delete') - async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { - const result = await this.service.batchRemove(body.ids); - await logAudit(this.logService, req, { - module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`, - }); - return result; - } - @Get('export/excel') @RequirePermission('bill:export-excel') async exportExcel( @@ -226,4 +130,157 @@ export class BillsController { }); return this.exportService.exportStudentPdf(id, res); } + + @Get(':id') + @RequirePermission('bill:view') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.service.findOne(id); + } + + @Put('batch/status') + @RequirePermission('bill:confirm') + async batchUpdateStatus(@Body() body: BatchIdsBodyDto, @Request() req: AuthenticatedRequest) { + const ids = body.ids || []; + // 更新前先取当前状态:只对「本次从非 paid 变为 paid」的账单发通知,幂等重确认不重复打扰 + const before = await this.billRepo.findBy({ id: In(ids) }); + const paidBefore = new Set( + before.filter((bill) => bill.status === 'paid').map((bill) => bill.id), + ); + const result = await this.service.batchUpdateStatus(ids, body.status); + await logAudit(this.logService, req, { + module: '账单管理', action: '确认账单', detail: `IDs: ${ids.join(',')}`, + }); + // Send bill_paid notifications (batch):DB 查询失败正常抛出,只有通知失败被吞(记日志) + const bills = await this.billRepo.findBy({ id: In(ids) }); + const paidBills = bills.filter((bill) => bill.status === 'paid' && !paidBefore.has(bill.id)); + try { + await this.sendPaidNotifications(this.studentRepo, this.notificationsService, paidBills); + } catch (err) { + this.logger.warn(`账单确认通知发送失败: ${String(err)}`); + } + return result; + } + + @Put(':id/status') + @RequirePermission('bill:confirm') + async updateStatus( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateBillStatusDto, + @Request() req: AuthenticatedRequest, + ) { + const prior = await this.billRepo.findOne({ where: { id } }); + const result = await this.service.updateStatus(id, dto); + await logAudit(this.logService, req, { + module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill', + }); + // 仅当本次从非 paid 变为 paid 才发通知(幂等),复用批量通知 helper + if (result.status === 'paid' && prior?.status !== 'paid') { + try { + await this.sendPaidNotifications(this.studentRepo, this.notificationsService, [result]); + } catch (err) { + this.logger.warn(`账单已确认通知发送失败: ${String(err)}`); + } + } + return result; + } + + @Post(':id/cancel') + @RequirePermission('bill:delete') + async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: AuthenticatedRequest) { + const result = await this.service.cancel(id, dto, req.user?.id); + await logAudit(this.logService, req, { + module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', + detail: Array.from(dto.reason ?? '') + .map((ch) => (ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127 ? ' ' : ch)) + .join('') + .trim(), + }); + return result; + } + + @Delete(':id') + @RequirePermission('bill:delete') + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + const result = await this.service.remove(id); + await logAudit(this.logService, req, { + module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('bill:purge') + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + const result = await this.service.purge(id); + await logAudit(this.logService, req, { + module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('bill:purge') + async batchPurge(@Body() body: BatchIdsDto, @Request() req: AuthenticatedRequest) { + const result = await this.service.batchPurge(body.ids || []); + await logAudit(this.logService, req, { + module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Post('batch/delete') + @RequirePermission('bill:delete') + async batchRemove(@Body() body: BatchIdsDto, @Request() req: AuthenticatedRequest) { + const ids = body.ids || []; + const result = await this.service.batchRemove(ids); + await logAudit(this.logService, req, { + module: '账单管理', action: '批量归档账单', detail: `IDs: ${ids.join(',')}`, + }); + return result; + } + + /** + * 批量预取学生并并发发送账单通知(generateBills/batchUpdateStatus 共用)。 + * 内部用 Promise.allSettled 并发发送:单个通知失败只记录日志、不影响其他通知与主流程。 + * notificationFor 缺省时发送「已确认收款」通知(batchUpdateStatus 场景)。 + */ + private async sendPaidNotifications( + studentRepo: Repository, + notificationsService: NotificationsService, + bills: Bill[], + notificationFor: ( + bill: Bill, + ) => { type: NotificationType; title: string; content: string } = (bill) => ({ + type: NotificationType.BILL_PAID, + title: '账单已确认', + content: `账单 #${bill.id} 已确认收款,金额: ¥${bill.totalAmount}`, + }), + ): Promise { + if (bills.length === 0) return; + // 批量预取学生(一次 In 查询),避免每张账单 studentRepo.findOne(N+1) + const studentIds = [...new Set(bills.map((bill) => bill.studentId))]; + const students = await studentRepo.find({ where: { id: In(studentIds) } }); + const userByStudentId = new Map( + students.filter((student) => student.userId).map((student) => [student.id, student.userId]), + ); + const results = await Promise.allSettled( + bills.map((bill) => { + const userId = userByStudentId.get(bill.studentId); + if (!userId) return Promise.resolve(undefined); + const notification = notificationFor(bill); + return notificationsService.create({ + recipientIds: [userId], + type: notification.type, + title: notification.title, + content: notification.content, + }); + }), + ); + for (const result of results) { + if (result.status === 'rejected') { + // 不吞错:失败记录到日志,通知失败不影响主流程 + this.logger.warn(`账单通知发送失败: ${String(result.reason)}`); + } + } + } } diff --git a/apps/server/src/bills/bills.service.spec.ts b/apps/server/src/bills/bills.service.spec.ts index 435c2ab0..bcfde9a4 100644 --- a/apps/server/src/bills/bills.service.spec.ts +++ b/apps/server/src/bills/bills.service.spec.ts @@ -71,7 +71,11 @@ describe('BillsService — generateBills', () => { await (itemRepo.save as jest.Mock)(value); return { id: value.id || 1, ...value }; }), - createQueryBuilder: jest.fn(() => ({ update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })), + createQueryBuilder: jest.fn(() => ({ setLock: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([]), update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })), + // advisory lock:事务内 GET_LOCK/RELEASE_LOCK 直接成功 + query: jest.fn(async (sql: string) => + sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }], + ), })), query: jest.fn().mockResolvedValue([]), }; @@ -390,26 +394,27 @@ describe('BillsService — generateBills', () => { ]), ); - // Use sequential query builder returns: first call → room 1 occs, second → room 2 occs + // Use sequential query builder returns: + // call 1 → 本周期长租入住(该测试无长租,返回空) + // call 2 → roomIds 批量入住查询(room 1 + room 2 全部在住记录) // NOTE: mock order depends on internal service call sequence; if refactored, update callCount indices let callCount = 0; (occRepo.createQueryBuilder as jest.Mock).mockImplementation(() => { callCount++; if (callCount === 1) { - return mockQueryBuilder([ - { - id: 1, studentId: 10, roomId: 1, - billingStartDate: '2026-06-01', billingEndDate: '2026-06-10', - stayType: 'short', room: undefined, - } as Occupancy, - { - id: 2, studentId: 11, roomId: 1, - billingStartDate: '2026-06-01', billingEndDate: '2026-06-20', - stayType: 'short', room: undefined, - } as Occupancy, - ]); + return mockQueryBuilder([]); } return mockQueryBuilder([ + { + id: 1, studentId: 10, roomId: 1, + billingStartDate: '2026-06-01', billingEndDate: '2026-06-10', + stayType: 'short', room: undefined, + } as Occupancy, + { + id: 2, studentId: 11, roomId: 1, + billingStartDate: '2026-06-01', billingEndDate: '2026-06-20', + stayType: 'short', room: undefined, + } as Occupancy, { id: 3, studentId: 12, roomId: 2, billingStartDate: '2026-06-01', billingEndDate: '2026-06-30', @@ -562,21 +567,25 @@ describe('BillsService — allocation rounding boundary', () => { create: (_entity: unknown, value: any) => value, save: jest.fn(async (value: any) => ({ id: value.id || ++nextBillId, ...value })), createQueryBuilder: jest.fn(() => ({ + setLock: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }), })), + // advisory lock:事务内 GET_LOCK/RELEASE_LOCK 直接成功 + query: jest.fn(async (sql: string) => + sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }], + ), })), }; const walletsService = { debitBill: jest.fn(async (_manager, bill) => bill) } as any; const generation = new BillsGenerationService( - billRepo as any, - itemRepo as any, roomExpRepo as any, personalExpRepo as any, occRepo as any, - roomRepo as any, dataSource as any, walletsService, ); diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index feab198b..37152447 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -12,6 +12,7 @@ import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill import { WalletsService } from '../wallets/wallets.service'; import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; import { BillsGenerationService } from './bills-generation.service'; +import { escapeLike } from '../common/like-escape'; interface AgentBillRow { billId: string | number; @@ -143,11 +144,11 @@ export class BillsService { const billId = Number(query.keyword); if (Number.isInteger(billId) && billId > 0) { qb.andWhere('(student.name LIKE :keyword OR bill.id = :billId)', { - keyword: `%${query.keyword}%`, + keyword: `%${escapeLike(query.keyword)}%`, billId, }); } else { - qb.andWhere('student.name LIKE :keyword', { keyword: `%${query.keyword}%` }); + qb.andWhere('student.name LIKE :keyword', { keyword: `%${escapeLike(query.keyword)}%` }); } } if (query.periodStart) diff --git a/apps/server/src/bills/dto/bill.dto.ts b/apps/server/src/bills/dto/bill.dto.ts index 55e73338..a5fff53d 100644 --- a/apps/server/src/bills/dto/bill.dto.ts +++ b/apps/server/src/bills/dto/bill.dto.ts @@ -1,4 +1,14 @@ -import { IsIn, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from 'class-validator'; export class GenerateBillsDto { @IsOptional() @@ -7,7 +17,7 @@ export class GenerateBillsDto { operationId?: string; @IsString() - @Matches(/^\d{4}-\d{2}$/) + @Matches(/^\d{4}-(0[1-9]|1[0-2])$/) billingMonth: string; @IsOptional() @@ -36,3 +46,14 @@ export class CancelBillDto { @MaxLength(300) reason: string; } + +/** 批量确认账单请求体:ids 校验为整数数组且最多 500 条。 */ +export class BatchIdsBodyDto { + @IsArray() + @IsInt({ each: true }) + @ArrayMaxSize(500) + ids: number[]; + + @IsIn(['unpaid', 'partially_paid', 'paid']) + status: 'unpaid' | 'partially_paid' | 'paid'; +} diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts index d35abc3a..2d6d0dd3 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts @@ -201,7 +201,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => { let scheduleRepo: jest.Mocked< Pick< Repository, - 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' + 'find' | 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' > >; @@ -226,17 +226,30 @@ describe('ClassroomRentalsService — rental schedule sync', () => { Pick, 'findOne'> >; + // syncScheduleFromRental 现在把 read+write 放进 scheduleRepo.manager 事务, + // 事务内通过 manager.getRepository(ClassSchedule) 拿到绑定事务的同一 mock。 + const scheduleManager = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => + cb({ + getRepository: jest.fn((entity: unknown) => + entity === ClassSchedule ? scheduleRepo : undefined, + ), + }), + ), + }; scheduleRepo = { + find: jest.fn(), findOne: jest.fn(), save: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), createQueryBuilder: jest.fn(), + manager: scheduleManager, } as jest.Mocked< Pick< Repository, - 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' + 'find' | 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' > >; @@ -265,8 +278,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => { const dto: CreateRentalDto = { classroomId: 1, lesseeOrganizationId: 2, - startDate: '2026-03-01', - endDate: '2026-03-31', + startDate: '2026-09-01', + endDate: '2026-09-30', }; const classroom = { id: 1, status: 'available' } as Classroom; const hostOrganization = { @@ -294,7 +307,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => { ); rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); - scheduleRepo.findOne.mockResolvedValue(null); + scheduleRepo.find.mockResolvedValue([]); scheduleRepo.create.mockImplementation( (entity) => ({ ...(entity as object) }) as ClassSchedule, ); @@ -310,8 +323,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => { classroomId: 1, lessorOrganizationId: 1, lesseeOrganizationId: 2, - startDate: '2026-03-01', - endDate: '2026-03-31', + startDate: '2026-09-01', + endDate: '2026-09-30', status: 'active', }), ); @@ -321,8 +334,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => { classId: null, startTime: '00:00', endTime: '23:59', - startDate: '2026-03-01', - endDate: '2026-03-31', + startDate: '2026-09-01', + endDate: '2026-09-30', subject: 'Organization A 租赁', teacherId: null, scheduleType: 'RENTAL', @@ -332,6 +345,63 @@ describe('ClassroomRentalsService — rental schedule sync', () => { ); expect(scheduleRepo.save).toHaveBeenCalled(); }); + + it('creates one RENTAL schedule row per weekday for a multi-day rental', async () => { + const dto: CreateRentalDto = { + classroomId: 1, + lesseeOrganizationId: 2, + startDate: '2026-09-01', // 周二 + endDate: '2026-09-05', // 周六 + }; + classroomRepo.findOne.mockResolvedValue({ id: 1, status: 'available' } as Classroom); + organizationRepo.findOne + .mockResolvedValueOnce({ + id: 1, + name: 'Host', + isHost: true, + status: 'active', + } as Organization) + .mockResolvedValueOnce({ + id: 2, + name: 'Organization A', + isHost: false, + status: 'active', + } as Organization); + rentalRepo.create.mockImplementation( + (entity) => ({ ...(entity as object) }) as ClassroomRental, + ); + rentalRepo.save.mockImplementation((entity) => + Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental), + ); + rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.find.mockResolvedValue([]); + scheduleRepo.create.mockImplementation( + (entity) => ({ ...(entity as object) }) as ClassSchedule, + ); + scheduleRepo.save.mockImplementation((entity) => + Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule), + ); + + await service.create(dto); + + expect(scheduleRepo.create).toHaveBeenCalledTimes(5); + const createdWeekDays = (scheduleRepo.create as jest.Mock).mock.calls + .map((call) => (call[0] as ClassSchedule).weekDay) + .sort((a, b) => a - b); + expect(createdWeekDays).toEqual([2, 3, 4, 5, 6]); + for (const call of (scheduleRepo.create as jest.Mock).mock.calls) { + expect(call[0]).toEqual( + expect.objectContaining({ + rentalId: 1, + scheduleType: 'RENTAL', + startDate: '2026-09-01', + endDate: '2026-09-05', + status: 'active', + }), + ); + } + }); }); describe('update()', () => { @@ -352,17 +422,20 @@ describe('ClassroomRentalsService — rental schedule sync', () => { startDate: '2026-08-01', endDate: '2099-04-30', }; - const existingSchedule = { - id: 50, + // 多日租赁已按周几展开为 7 行排课(weekDay 1..7) + const existingSchedules = [1, 2, 3, 4, 5, 6, 7].map((weekDay) => ({ + id: 49 + weekDay, rentalId: 1, scheduleType: 'RENTAL', classroomId: 1, - } as ClassSchedule; + weekDay, + status: 'active', + })) as ClassSchedule[]; rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(updatedRental); rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); - scheduleRepo.findOne.mockResolvedValue(existingSchedule); + scheduleRepo.find.mockResolvedValue(existingSchedules); const dto: UpdateRentalDto = { startDate: '2026-08-01', endDate: '2099-04-30' }; await service.update(1, dto); @@ -387,6 +460,55 @@ describe('ClassroomRentalsService — rental schedule sync', () => { expect(scheduleRepo.delete).not.toHaveBeenCalled(); }); + it('cancels RENTAL schedule rows for weekdays no longer covered after the rental is shortened', async () => { + const existingRental = { + id: 1, + classroomId: 1, + lesseeOrganizationId: 2, + startDate: '2026-09-01', + endDate: '2026-09-07', + status: 'active', + notes: '', + lesseeOrganization: { id: 2, name: 'Organization A' } as Organization, + classroom: { id: 1 } as Classroom, + } as ClassroomRental; + const shortenedRental = { ...existingRental, endDate: '2026-09-02' }; + const existingSchedules = [1, 2, 3, 4, 5, 6, 7].map((weekDay) => ({ + id: 49 + weekDay, + rentalId: 1, + scheduleType: 'RENTAL', + classroomId: 1, + weekDay, + status: 'active', + })) as ClassSchedule[]; + + rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(shortenedRental); + rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.find.mockResolvedValue(existingSchedules); + + const dto: UpdateRentalDto = { endDate: '2026-09-02' }; + await service.update(1, dto); + + // 2026-09-01(周二)=2、2026-09-02(周三)=3 保留为 active,其余周几行置为 cancelled + expect(scheduleRepo.update).toHaveBeenCalledWith( + 50, + expect.objectContaining({ status: 'cancelled' }), + ); + expect(scheduleRepo.update).toHaveBeenCalledWith( + 54, + expect.objectContaining({ status: 'cancelled' }), + ); + expect(scheduleRepo.update).toHaveBeenCalledWith( + 51, + expect.objectContaining({ status: 'active', weekDay: 2 }), + ); + expect(scheduleRepo.update).toHaveBeenCalledWith( + 52, + expect.objectContaining({ status: 'active', weekDay: 3 }), + ); + }); + it('deactivates the RENTAL schedule row when the rental is cancelled', async () => { const rental = { id: 1, @@ -423,7 +545,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => { } as ClassroomRental; const ended = { ...rental, status: 'ended', endDate: '2026-07-13' } as ClassroomRental; rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(ended); - scheduleRepo.findOne.mockResolvedValue({ id: 50 } as ClassSchedule); + scheduleRepo.find.mockResolvedValue([{ id: 50, weekDay: 3, status: 'active' } as ClassSchedule]); await service.end(1); @@ -431,7 +553,10 @@ describe('ClassroomRentalsService — rental schedule sync', () => { 1, expect.objectContaining({ status: 'ended' }), ); - expect(scheduleRepo.update).toHaveBeenCalled(); + expect(scheduleRepo.update).toHaveBeenCalledWith( + 50, + expect.objectContaining({ status: 'cancelled' }), + ); }); it('rejects ending a future rental', async () => { @@ -492,12 +617,18 @@ describe('ClassroomRentalsService — organization roles', () => { .mockResolvedValueOnce({ id: 2, name: '合作机构', isHost: false, status: 'active' }), } as any; const scheduleRepo = { + find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null), save: jest.fn(async (value) => ({ ...value, id: 100 })), create: jest.fn((value) => value), update: jest.fn(), delete: jest.fn(), createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + manager: { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => + cb({ getRepository: jest.fn((entity: unknown) => (entity === ClassSchedule ? scheduleRepo : undefined)) }), + ), + }, } as any; const scheduleService = new RentalScheduleService(rentalRepo, classroomRepo, scheduleRepo); diff --git a/apps/server/src/classroom-rentals/rental-schedule.service.ts b/apps/server/src/classroom-rentals/rental-schedule.service.ts index 15cf14f5..c1e02671 100644 --- a/apps/server/src/classroom-rentals/rental-schedule.service.ts +++ b/apps/server/src/classroom-rentals/rental-schedule.service.ts @@ -1,9 +1,23 @@ import { ConflictException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; -import { ClassroomRental, Classroom, ClassSchedule, ClassroomStatus } from '../entities'; +import { + ClassroomRental, + Classroom, + ClassSchedule, + ClassroomStatus, + ScheduleType, +} from '../entities'; import { ClassroomRentalStatus } from '../entities/classroom-rental.entity'; import dayjs from '../common/dayjs'; +import { addDaysToDateOnly, getWeekDayFromDateOnly } from '../common/china-time'; + +// TODO: matrix 格子整体暂保持 any;统计过滤时用最小显式形状避免 unsafe any 访问 +interface ScheduleMatrixCell { + scheduleType?: string; + status?: string; + [key: string]: unknown; +} const COLOR_PALETTE = [ "#5B8FF9", @@ -43,8 +57,9 @@ export class RentalScheduleService { this.scheduleRepo.find({ where: { classroomId, - status: ClassroomRentalStatus.ACTIVE, - scheduleType: 'INTERNAL', + // ClassSchedule.status 实体类型为 string,保留字面量 + status: 'active', + scheduleType: ScheduleType.INTERNAL, startDate: LessThanOrEqual(monthEnd), endDate: MoreThanOrEqual(monthStart), }, @@ -85,8 +100,9 @@ export class RentalScheduleService { const scheduleCandidates = await this.scheduleRepo .createQueryBuilder('cs') .where('cs.classroomId = :cid', { cid: classroomId }) + // ClassSchedule.status 实体类型为 string,保留字面量 .andWhere('cs.status = :status', { status: 'active' }) - .andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' }) + .andWhere('cs.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL }) .andWhere('cs.startDate <= :end', { end: endDate }) .andWhere('cs.endDate >= :start', { start: startDate }) .getMany(); @@ -118,25 +134,21 @@ export class RentalScheduleService { const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; if (overlapStart > overlapEnd) return false; - const startUtc = this.toUtcDate(overlapStart); - const endUtc = this.toUtcDate(overlapEnd); - const startWeekDay = startUtc.getUTCDay() || 7; - const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7; - startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence); - return startUtc <= endUtc; - } - - private toUtcDate(date: string): Date { - const [year, month, day] = date.split('-').map(Number); - return new Date(Date.UTC(year, month - 1, day)); + // YYYY-MM-DD 字典序即时间序;weekDay 取中国日历星期(1=周一 … 7=周日), + // 统一走 common/china-time,避免本地时区 getters 造成偏移。 + const startWeekDay = getWeekDayFromDateOnly(overlapStart); + const firstOccurrence = addDaysToDateOnly( + overlapStart, + (schedule.weekDay - startWeekDay + 7) % 7, + ); + return firstOccurrence <= overlapEnd; } private addDateRange(dates: Set, startDate: string, endDate: string) { - const current = this.toUtcDate(startDate); - const end = this.toUtcDate(endDate); - while (current <= end) { - dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD')); - current.setUTCDate(current.getUTCDate() + 1); + let current = startDate; + while (current <= endDate) { + dates.add(current); + current = addDaysToDateOnly(current, 1); } } @@ -150,13 +162,11 @@ export class RentalScheduleService { const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; if (overlapStart > overlapEnd) return; - const current = this.toUtcDate(overlapStart); - const end = this.toUtcDate(overlapEnd); - const startWeekDay = current.getUTCDay() || 7; - current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7)); - while (current <= end) { - dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD')); - current.setUTCDate(current.getUTCDate() + 7); + const startWeekDay = getWeekDayFromDateOnly(overlapStart); + let current = addDaysToDateOnly(overlapStart, (schedule.weekDay - startWeekDay + 7) % 7); + while (current <= overlapEnd) { + dates.add(current); + current = addDaysToDateOnly(current, 7); } } @@ -180,6 +190,7 @@ export class RentalScheduleService { .andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }) .getMany(); + // TODO: 收敛为显式类型(matrix 格子含 scheduleType/status 等字段,涉及面较大暂保持 any) const organizationMap = new Map(); const matrix: Record> = {}; const summary: Record< @@ -193,12 +204,9 @@ export class RentalScheduleService { } for (const rental of rentals) { - const start = new Date(rental.startDate); - const end = new Date(rental.endDate); - const monthStart = new Date(first); - const monthEnd = new Date(last); - const effStart = start < monthStart ? monthStart : start; - const effEnd = end > monthEnd ? monthEnd : end; + // YYYY-MM-DD 字典序即时间序,直接字符串比较,避免本地时区解析偏移 + const effStart = rental.startDate > first ? rental.startDate : first; + const effEnd = rental.endDate < last ? rental.endDate : last; if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) { organizationMap.set(rental.lesseeOrganization.id, { id: rental.lesseeOrganization.id, @@ -208,11 +216,13 @@ export class RentalScheduleService { COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length], }); } - for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { - const day = d.getDate(); - if (!matrix[rental.classroomId]) continue; + let d = effStart; + while (d <= effEnd) { + const day = Number(d.slice(8, 10)); + if (matrix[rental.classroomId]) { matrix[rental.classroomId][day] = { - scheduleType: 'RENTAL', + scheduleType: ScheduleType.RENTAL, + status: rental.status, rentalId: rental.id, organizationId: rental.lesseeOrganizationId, organizationName: rental.lesseeOrganization?.name || '未知', @@ -221,6 +231,8 @@ export class RentalScheduleService { COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length], hasContract: !!rental.contractPath, }; + } + d = addDaysToDateOnly(d, 1); } } @@ -229,26 +241,25 @@ export class RentalScheduleService { .createQueryBuilder('s') .leftJoinAndSelect('s.class', 'class') .leftJoinAndSelect('s.teacher', 'teacher') + // ClassSchedule.status 实体类型为 string,保留字面量 .where('s.status = :active', { active: 'active' }) - .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) + .andWhere('s.scheduleType = :type', { type: ScheduleType.INTERNAL }) .andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last }) .getMany(); for (const sched of schedules) { if (!sched.classroomId) continue; - const schedStart = new Date( - Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()), - ); - const schedEnd = new Date( - Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()), - ); - for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) { - const dow = d.getDay() === 0 ? 7 : d.getDay(); - if (dow !== sched.weekDay) continue; - const day = d.getDate(); - if (!matrix[sched.classroomId]) continue; + const schedStart = sched.startDate > first ? sched.startDate : first; + const schedEnd = sched.endDate < last ? sched.endDate : last; + let d = schedStart; + while (d <= schedEnd) { + const dow = getWeekDayFromDateOnly(d); + const day = Number(d.slice(8, 10)); + if (dow === sched.weekDay && matrix[sched.classroomId]) { matrix[sched.classroomId][day] = { - scheduleType: 'INTERNAL', + scheduleType: ScheduleType.INTERNAL, + // ClassSchedule.status 实体类型为 string,保留字面量 + status: 'active', scheduleId: sched.id, className: (sched.class as { name?: string } | null)?.name || '', subject: sched.subject, @@ -257,11 +268,17 @@ export class RentalScheduleService { endTime: sched.endTime, color: '#52c41a', }; + } + d = addDaysToDateOnly(d, 1); } } - // 统计 + // 统计:只统计 RENTAL 且非 cancelled 的天数——内部排课覆盖格(INTERNAL)与 + // cancelled 行不计入租赁占用(matrix 每格带 scheduleType/status,按需过滤)。 for (const cls of classrooms) { - const rented = Object.keys(matrix[cls.id]).length; + const rented = Object.values(matrix[cls.id]).filter((cell) => { + const typed = cell as ScheduleMatrixCell | undefined; + return typed?.scheduleType === ScheduleType.RENTAL && typed?.status !== 'cancelled'; + }).length; summary[cls.id].rentedDays = rented; summary[cls.id].idleDays = lastDay - rented; summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0; @@ -299,38 +316,78 @@ export class RentalScheduleService { async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) { const name = organizationName || rental.lesseeOrganization?.name || '承租机构'; - const weekDay = this.dateToWeekDay(rental.startDate); - let schedule = await this.scheduleRepo.findOne({ - where: { rentalId: rental.id, scheduleType: 'RENTAL' }, - }); - const data = { - classroomId: rental.classroomId, - classId: null, - weekDay, - startTime: '00:00', - endTime: '23:59', - startDate: rental.startDate, - endDate: rental.endDate, - subject: `${name} 租赁`, - teacherId: null, - scheduleType: 'RENTAL', - rentalId: rental.id, - status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active', - notes: rental.notes, - }; - if (schedule) { - await this.scheduleRepo.update(schedule.id, data); - } else { - schedule = this.scheduleRepo.create(data); - await this.scheduleRepo.save(schedule); + // 已结束的租赁(状态为 ENDED,或 ACTIVE 但 endDate 早于今天)与已取消一致, + // 不再写入/更新为 active 排课,统一映射为 cancelled。 + const today = dayjs().utcOffset(8).format('YYYY-MM-DD'); + const isInactive = + rental.status === ClassroomRentalStatus.CANCELLED || + rental.status === ClassroomRentalStatus.ENDED || + (rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today); + + // 多日租赁:按 startDate..endDate 覆盖的每一天(用 addDaysToDateOnly 迭代)展开成周几, + // 每个工作日对应一条 RENTAL 排课,weekDay 取当天星期(1=周一 … 7=周日), + // 与 getSchedule/findConflicts 按 weekDay 匹配的语义一致。 + const coveredWeekDays = new Set(); + for ( + let day = rental.startDate; + day <= rental.endDate; + day = addDaysToDateOnly(day, 1) + ) { + coveredWeekDays.add(this.dateToWeekDay(day)); } + + // read-then-multiple-write(cancel 旧行 / update 覆盖行 / insert 新行)放进同一事务, + // 任一写入失败则整体回滚,避免残留半同步状态。 + await this.scheduleRepo.manager.transaction(async (manager) => { + // 事务内使用 manager.getRepository 获得绑定到该事务的排课仓库 + const scheduleRepo = manager.getRepository(ClassSchedule); + const existingSchedules = await scheduleRepo.find({ + where: { rentalId: rental.id, scheduleType: ScheduleType.RENTAL }, + }); + const existingByWeekDay = new Map(); + for (const schedule of existingSchedules) { + existingByWeekDay.set(schedule.weekDay, schedule); + } + + // 租赁缩短/换教室后不再覆盖的周几,旧排课行统一映射为 cancelled,避免残留旧占位 + // (ClassSchedule.status 实体类型为 string,保留 'cancelled' 字面量) + for (const schedule of existingSchedules) { + if (!coveredWeekDays.has(schedule.weekDay) && schedule.status !== 'cancelled') { + await scheduleRepo.update(schedule.id, { status: 'cancelled' }); + } + } + + for (const weekDay of coveredWeekDays) { + const data = { + classroomId: rental.classroomId, + classId: null, + weekDay, + startTime: '00:00', + endTime: '23:59', + startDate: rental.startDate, + endDate: rental.endDate, + subject: `${name} 租赁`, + teacherId: null, + scheduleType: ScheduleType.RENTAL, + rentalId: rental.id, + // ClassSchedule.status 实体类型为 string,保留 'active'/'cancelled' 字面量 + status: isInactive ? 'cancelled' : 'active', + notes: rental.notes, + }; + const schedule = existingByWeekDay.get(weekDay); + if (schedule) { + await scheduleRepo.update(schedule.id, data); + } else { + const created = scheduleRepo.create(data); + await scheduleRepo.save(created); + } + } + }); } dateToWeekDay(date: string): number { - const d = new Date(date); - const day = d.getDay(); - return day === 0 ? 7 : day; + return getWeekDayFromDateOnly(date); } } diff --git a/apps/server/src/common/china-time.spec.ts b/apps/server/src/common/china-time.spec.ts new file mode 100644 index 00000000..501004ad --- /dev/null +++ b/apps/server/src/common/china-time.spec.ts @@ -0,0 +1,38 @@ +import { + CHINA_UTC_OFFSET, + addDaysToDateOnly, + getWeekDayFromDateOnly, + isConsecutiveDates, + parseChinaDateOnly, +} from './china-time'; + +describe('china-time', () => { + it('exports UTC+8 offset', () => { + expect(CHINA_UTC_OFFSET).toBe(8); + }); + + it('addDaysToDateOnly crosses month/year boundaries', () => { + expect(addDaysToDateOnly('2026-02-28', 1)).toBe('2026-03-01'); + expect(addDaysToDateOnly('2026-12-31', 1)).toBe('2027-01-01'); + expect(addDaysToDateOnly('2026-01-01', -1)).toBe('2025-12-31'); + expect(addDaysToDateOnly('2026-08-09', 7)).toBe('2026-08-16'); + }); + + it('getWeekDayFromDateOnly is timezone independent', () => { + // 2026-08-09 是周日 → 7 + expect(getWeekDayFromDateOnly('2026-08-09')).toBe(7); + // 2026-08-10 是周一 → 1 + expect(getWeekDayFromDateOnly('2026-08-10')).toBe(1); + }); + + it('parseChinaDateOnly anchors to UTC+8 midnight', () => { + const d = parseChinaDateOnly('2026-08-09'); + expect(d.toISOString()).toBe('2026-08-08T16:00:00.000Z'); + }); + + it('isConsecutiveDates detects adjacent dates', () => { + expect(isConsecutiveDates('2026-08-09', '2026-08-10')).toBe(true); + expect(isConsecutiveDates('2026-08-09', '2026-08-11')).toBe(false); + expect(isConsecutiveDates('2026-08-09', '2026-08-09')).toBe(false); + }); +}); diff --git a/apps/server/src/common/china-time.ts b/apps/server/src/common/china-time.ts new file mode 100644 index 00000000..054a478e --- /dev/null +++ b/apps/server/src/common/china-time.ts @@ -0,0 +1,39 @@ +import dayjs from './dayjs'; + +/** + * 中国时区(Asia/Shanghai)常量与日期工具。 + * 服务端/生产统一按 UTC+8 处理「业务日期」,避免与服务器本地时区混用导致 off-by-one。 + */ +export const CHINA_UTC_OFFSET = 8; + +/** 当前时刻的中国日期(YYYY-MM-DD)。 */ +export function getChinaDate(date: Date = new Date()): string { + return dayjs(date).utcOffset(CHINA_UTC_OFFSET).format('YYYY-MM-DD'); +} + +/** 把 'YYYY-MM-DD' 按 UTC+8 午夜解析为 Date(用于需要绝对时刻的场合)。 */ +export function parseChinaDateOnly(dateOnly: string): Date { + return new Date(`${dateOnly}T00:00:00+08:00`); +} + +/** 纯日期字符串加减天数,避免本地时区 getters 造成的偏移。 */ +export function addDaysToDateOnly(dateOnly: string, days: number): string { + const [y, m, d] = dateOnly.split('-').map(Number); + const dt = new Date(Date.UTC(y, m - 1, d + days)); + const yy = dt.getUTCFullYear(); + const mm = String(dt.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(dt.getUTCDate()).padStart(2, '0'); + return `${yy}-${mm}-${dd}`; +} + +/** 返回 dateOnly 在中国日历下的星期(1=周一 … 7=周日),与本地时区无关。 */ +export function getWeekDayFromDateOnly(dateOnly: string): number { + const [y, m, d] = dateOnly.split('-').map(Number); + const day = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); + return day === 0 ? 7 : day; +} + +/** 两个 YYYY-MM-DD 是否按中国日历相邻(差 1 天)。 */ +export function isConsecutiveDates(a: string, b: string): boolean { + return addDaysToDateOnly(a, 1) === b || addDaysToDateOnly(b, 1) === a; +} diff --git a/apps/server/src/deposits/deposits.purge.spec.ts b/apps/server/src/deposits/deposits.purge.spec.ts index e8ab93f8..55abc879 100644 --- a/apps/server/src/deposits/deposits.purge.spec.ts +++ b/apps/server/src/deposits/deposits.purge.spec.ts @@ -13,7 +13,11 @@ describe('DepositsService.purge', () => { ...overrides?.deposit, }; const repo = { - findOne: jest.fn().mockResolvedValue(deposit), + // purge 现在按 id + 已归档状态查询;无状态条件时(fallback)返回记录本体 + findOne: jest.fn(async ({ where }: { where: { id?: number; status?: string } }) => { + if (where?.status === 'archived') return deposit.status === 'archived' ? deposit : null; + return deposit; + }), delete: jest.fn().mockResolvedValue({ affected: 1 }), }; const installmentRepo = { count: jest.fn().mockResolvedValue(0) }; @@ -55,11 +59,18 @@ describe('DepositsService.purge', () => { expect(repo.delete).not.toHaveBeenCalled(); }); + it('does not delete when the deposit is no longer archived by delete time', async () => { + const { service, repo } = createService(); + repo.delete.mockResolvedValue({ affected: 0 }); + await expect(service.purge(1)).rejects.toBeInstanceOf(BadRequestException); + expect(repo.delete).toHaveBeenCalledWith({ id: 1, status: 'archived' }); + }); + it('deletes an archived deposit with no paid history', async () => { const { service, repo } = createService(); await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除押金(不可恢复)', }); - expect(repo.delete).toHaveBeenCalledWith(1); + expect(repo.delete).toHaveBeenCalledWith({ id: 1, status: 'archived' }); }); }); diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index b4d4bcee..6de71d0c 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -1,12 +1,13 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Not, Repository } from 'typeorm'; import { Deposit } from '../entities/deposit.entity'; import { Student } from '../entities/student.entity'; import { DepositInstallment } from '../entities/deposit-installment.entity'; import { Occupancy } from '../entities/occupancy.entity'; import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; +import { escapeLike } from '../common/like-escape'; const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); @@ -195,7 +196,7 @@ export class DepositsService { if (query?.keyword) { qb.andWhere( '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', - { keyword: `%${query.keyword}%` }, + { keyword: `%${escapeLike(query.keyword)}%` }, ); } if (query?.status && query.status !== 'archived') { @@ -236,7 +237,10 @@ export class DepositsService { } if (amount <= 0) throw new BadRequestException('收取金额必须大于0'); - const existing = await this.repo.findOne({ where: { studentId: dto.studentId } }); + // 只匹配未归档押金:避免给学生新建押金时错误地复活/修改已归档记录 + const existing = await this.repo.findOne({ + where: { studentId: dto.studentId, status: Not('archived') }, + }); if (existing) { existing.amount = money(Number(existing.amount || 0) + amount); existing.paidDate = dto.paidDate; @@ -326,10 +330,12 @@ export class DepositsService { } async purge(id: number) { - const deposit = await this.repo.findOne({ where: { id } }); - if (!deposit) throw new NotFoundException('押金记录不存在'); - if (deposit.status !== 'archived') { - throw new BadRequestException('仅已归档押金可以永久删除,请先归档'); + // 明确按已归档状态查询:避免 findOne 匹配到被 create() 复活/修改的新押金记录 + const deposit = await this.repo.findOne({ where: { id, status: 'archived' } }); + if (!deposit) { + const existing = await this.repo.findOne({ where: { id } }); + if (existing) throw new BadRequestException('仅已归档押金可以永久删除,请先归档'); + throw new NotFoundException('押金记录不存在'); } if (Number(deposit.refundAmount || 0) > 0) { throw new BadRequestException('该押金已有退款金额,无法永久删除'); @@ -343,7 +349,11 @@ export class DepositsService { if (paidInstallments > 0) { throw new BadRequestException('该押金存在已支付分期,无法永久删除'); } - await this.repo.delete(id); + // 条件删除:即使读取后状态被并发修改(例如被 create() 复活),也只删除仍处于已归档的记录 + const result = await this.repo.delete({ id, status: 'archived' }); + if (!result.affected) { + throw new BadRequestException('押金状态已变化,请刷新后重试'); + } return { message: '已永久删除押金(不可恢复)' }; } diff --git a/apps/server/src/entities/bill.entity.ts b/apps/server/src/entities/bill.entity.ts index 841509c2..c9565408 100644 --- a/apps/server/src/entities/bill.entity.ts +++ b/apps/server/src/entities/bill.entity.ts @@ -24,22 +24,57 @@ export class Bill { @Column({ name: 'period_end', type: 'date' }) periodEnd: string; - @Column({ name: 'shared_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ + name: 'shared_amount', + type: 'decimal', + precision: 10, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) sharedAmount: number; - @Column({ name: 'personal_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ + name: 'personal_amount', + type: 'decimal', + precision: 10, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) personalAmount: number; - @Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ + name: 'total_amount', + type: 'decimal', + precision: 10, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) totalAmount: number; @Column({ type: 'varchar', length: 30, default: 'batch' }) source: 'batch' | 'student_utility'; - @Column({ name: 'paid_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ + name: 'paid_amount', + type: 'decimal', + precision: 10, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) paidAmount: number; - @Column({ name: 'outstanding_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ + name: 'outstanding_amount', + type: 'decimal', + precision: 10, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) outstandingAmount: number; @Column({ type: 'varchar', length: 20, default: 'unpaid' }) diff --git a/apps/server/src/entities/class-student.entity.ts b/apps/server/src/entities/class-student.entity.ts index 7a6ca732..93069411 100644 --- a/apps/server/src/entities/class-student.entity.ts +++ b/apps/server/src/entities/class-student.entity.ts @@ -26,7 +26,7 @@ export class ClassStudent { @Column({ name: 'student_id', type: 'integer' }) studentId: number; - @ManyToOne(() => Student) + @ManyToOne(() => Student, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'student_id' }) student: Student; diff --git a/apps/server/src/entities/ding-attendance-raw.entity.ts b/apps/server/src/entities/ding-attendance-raw.entity.ts index f97958a8..e89157d1 100644 --- a/apps/server/src/entities/ding-attendance-raw.entity.ts +++ b/apps/server/src/entities/ding-attendance-raw.entity.ts @@ -57,11 +57,11 @@ export class DingAttendanceRaw { matchStatus: string; @Column({ name: 'matched_student_id', type: 'integer', nullable: true }) - matchedStudentId: number; + matchedStudentId: number | null; @ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'matched_student_id' }) - matchedStudent: Student; + matchedStudent: Student | null; @Column({ name: 'raw_data', type: 'text', nullable: true }) rawData: string; diff --git a/apps/server/src/entities/ding-leave-raw.entity.ts b/apps/server/src/entities/ding-leave-raw.entity.ts index f348ff5c..b2cf829e 100644 --- a/apps/server/src/entities/ding-leave-raw.entity.ts +++ b/apps/server/src/entities/ding-leave-raw.entity.ts @@ -54,11 +54,11 @@ export class DingLeaveRaw { matchStatus: string; @Column({ name: 'matched_student_id', type: 'integer', nullable: true }) - matchedStudentId: number; + matchedStudentId: number | null; @ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'matched_student_id' }) - matchedStudent: Student; + matchedStudent: Student | null; @Column({ name: 'raw_data', type: 'text', nullable: true }) rawData: string; diff --git a/apps/server/src/entities/learning-record.entity.ts b/apps/server/src/entities/learning-record.entity.ts index 8fb544e1..4b72f828 100644 --- a/apps/server/src/entities/learning-record.entity.ts +++ b/apps/server/src/entities/learning-record.entity.ts @@ -4,10 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, - ManyToOne, - JoinColumn, } from 'typeorm'; -import { Student } from './student.entity'; @Entity('learning_records') export class LearningRecord { @@ -17,10 +14,6 @@ export class LearningRecord { @Column({ name: 'student_id', type: 'integer' }) studentId: number; - @ManyToOne(() => Student, { eager: true }) - @JoinColumn({ name: 'student_id' }) - student: Student; - @Column({ name: 'record_date', type: 'date', nullable: true }) recordDate: string; diff --git a/apps/server/src/entities/student-profile.entity.ts b/apps/server/src/entities/student-profile.entity.ts index 7b687b48..f9de49d7 100644 --- a/apps/server/src/entities/student-profile.entity.ts +++ b/apps/server/src/entities/student-profile.entity.ts @@ -4,10 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, - ManyToOne, - JoinColumn, } from 'typeorm'; -import { Student } from './student.entity'; @Entity('student_profiles') export class StudentProfile { @@ -17,10 +14,6 @@ export class StudentProfile { @Column({ name: 'student_id', type: 'integer', unique: true }) studentId: number; - @ManyToOne(() => Student, { eager: true }) - @JoinColumn({ name: 'student_id' }) - student: Student; - @Column({ name: 'target_college', length: 100, nullable: true }) targetCollege: string; diff --git a/apps/server/src/entities/student-wallet.entity.ts b/apps/server/src/entities/student-wallet.entity.ts index 1e679004..cb50e732 100644 --- a/apps/server/src/entities/student-wallet.entity.ts +++ b/apps/server/src/entities/student-wallet.entity.ts @@ -17,7 +17,13 @@ export class StudentWallet { @Column({ name: 'student_id', type: 'integer', unique: true }) studentId: number; - @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + @Column({ + type: 'decimal', + precision: 12, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) balance: number; @CreateDateColumn({ name: 'created_at' }) diff --git a/apps/server/src/exams/exams.purge.spec.ts b/apps/server/src/exams/exams.purge.spec.ts index 54af2f25..4949033e 100644 --- a/apps/server/src/exams/exams.purge.spec.ts +++ b/apps/server/src/exams/exams.purge.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { ExamsService } from './exams.service'; +import { Exam } from '../entities'; describe('ExamsService.purge', () => { const createService = (overrides?: { exam?: Record }) => { @@ -10,13 +11,23 @@ describe('ExamsService.purge', () => { find: jest.fn().mockResolvedValue([exam]), }; const classTeacherRepo = { findOne: jest.fn().mockResolvedValue({}) }; + const dataSource = { + transaction: jest.fn(async (cb: (manager: any) => unknown) => + cb({ + getRepository: jest.fn((entity: unknown) => { + if (entity === Exam) return examRepo; + throw new Error(`Unexpected repository: ${String(entity)}`); + }), + }), + ), + }; const service = new ExamsService( examRepo as never, {} as never, {} as never, {} as never, classTeacherRepo as never, - {} as never, + dataSource as never, ); return { service, examRepo, classTeacherRepo }; }; diff --git a/apps/server/src/exams/exams.service.spec.ts b/apps/server/src/exams/exams.service.spec.ts index e633bd9d..b205377d 100644 --- a/apps/server/src/exams/exams.service.spec.ts +++ b/apps/server/src/exams/exams.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException, ForbiddenException, NotFoundException, ValidationPipe } from '@nestjs/common'; -import { ExamScore } from '../entities'; +import { Exam, ExamScore } from '../entities'; import { QueryExamDto } from './dto/exam.dto'; import { ExamsService } from './exams.service'; @@ -36,6 +36,16 @@ function updateQb(affected = 1) { return qb; } +function runWithExamRepo(examRepo: Record) { + return async (run: (manager: any) => Promise) => + run({ + getRepository: jest.fn((entity: unknown) => { + if (entity === Exam) return examRepo; + throw new Error(`Unexpected repository: ${String(entity)}`); + }), + }); +} + describe('ExamsService', () => { it('creates score rows from the active class roster snapshot', async () => { const members = [ @@ -260,7 +270,7 @@ describe('ExamsService', () => { createQueryBuilder: jest.fn(() => qb), }; const scoreRepo = { update: jest.fn(), save: jest.fn() }; - const service = createService(async () => undefined, { examRepo, scoreRepo }); + const service = createService(runWithExamRepo(examRepo), { examRepo, scoreRepo }); await expect(service.batchArchive([8, 8, 9], 1, true)).resolves.toEqual({ message: '已批量归档 1 场考试', @@ -283,7 +293,7 @@ describe('ExamsService', () => { ]), createQueryBuilder: jest.fn(() => qb), }; - const service = createService(async () => undefined, { examRepo }); + const service = createService(runWithExamRepo(examRepo), { examRepo }); await expect(service.batchRestore([8, 9], 1, true)).resolves.toEqual({ message: '已批量恢复 1 场考试', diff --git a/apps/server/src/exams/exams.service.ts b/apps/server/src/exams/exams.service.ts index 8f543edd..04ed1d5f 100644 --- a/apps/server/src/exams/exams.service.ts +++ b/apps/server/src/exams/exams.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, In, Like, Repository } from 'typeorm'; import { Class, ClassStudent, ClassTeacher, Exam, ExamScore } from '../entities'; import { CreateExamDto, QueryExamDto } from './dto/exam.dto'; +import { escapeLike } from '../common/like-escape'; @Injectable() export class ExamsService { @@ -31,7 +32,7 @@ export class ExamsService { const where: Record = { status: query.isArchived ? 'archived' : 'active', }; - if (query.keyword) where.examName = Like(`%${query.keyword}%`); + if (query.keyword) where.examName = Like(`%${escapeLike(query.keyword)}%`); if (query.examType) where.examType = query.examType; if (query.classId) where.classId = query.classId; if (accessibleClassIds) { @@ -205,27 +206,33 @@ export class ExamsService { async batchPurge(ids: number[], userId: number, canManageAll: boolean) { const exams = await this.findBatchExams(ids, userId, canManageAll, '永久删除'); - const deleted: number[] = []; - const skipped: string[] = []; - for (const exam of exams) { - if (exam.status !== 'archived') { - skipped.push(`${exam.examName}(未归档)`); - continue; + // 循环内逐条删除放进同一事务:任一删除失败则整批回滚(全有或全无)。 + return this.dataSource.transaction(async (manager) => { + const examRepo = manager.getRepository(Exam); + const deleted: number[] = []; + const skipped: string[] = []; + for (const exam of exams) { + if (exam.status !== 'archived') { + skipped.push(`${exam.examName}(未归档)`); + continue; + } + await examRepo.delete(exam.id); + deleted.push(exam.id); } - await this.examRepo.delete(exam.id); - deleted.push(exam.id); - } - const message = - skipped.length > 0 - ? `已永久删除 ${deleted.length} 场考试;${skipped.length} 场被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` - : `已永久删除 ${deleted.length} 场考试(不可恢复)`; - return { message, deleted: deleted.length, skipped: skipped.length }; + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 场考试;${skipped.length} 场被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 场考试(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + }); } async batchArchive(ids: number[], userId: number, canManageAll: boolean) { const exams = await this.findBatchExams(ids, userId, canManageAll, '归档'); const targetIds = exams.filter((exam) => exam.status === 'active').map((exam) => exam.id); - const archived = await this.updateBatchStatus(targetIds, 'archived'); + const archived = await this.dataSource.transaction(async (manager) => + this.updateBatchStatus(manager, targetIds, 'archived'), + ); return { message: `已批量归档 ${archived} 场考试`, archived, @@ -236,7 +243,9 @@ export class ExamsService { async batchRestore(ids: number[], userId: number, canManageAll: boolean) { const exams = await this.findBatchExams(ids, userId, canManageAll, '恢复'); const targetIds = exams.filter((exam) => exam.status === 'archived').map((exam) => exam.id); - const restored = await this.updateBatchStatus(targetIds, 'active'); + const restored = await this.dataSource.transaction(async (manager) => + this.updateBatchStatus(manager, targetIds, 'active'), + ); return { message: `已批量恢复 ${restored} 场考试`, restored, @@ -263,9 +272,14 @@ export class ExamsService { return exams; } - private async updateBatchStatus(ids: number[], status: 'active' | 'archived') { + private async updateBatchStatus( + manager: EntityManager, + ids: number[], + status: 'active' | 'archived', + ) { if (ids.length === 0) return 0; - const result = await this.examRepo + const result = await manager + .getRepository(Exam) .createQueryBuilder() .update() .set({ status }) diff --git a/apps/server/src/expense-types/expense-types.service.ts b/apps/server/src/expense-types/expense-types.service.ts index ecd8dae7..26153953 100644 --- a/apps/server/src/expense-types/expense-types.service.ts +++ b/apps/server/src/expense-types/expense-types.service.ts @@ -53,7 +53,11 @@ export class ExpenseTypesService { async create(dto: CreateExpenseTypeDto): Promise { const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() }; - const exists = await this.repo.findOne({ where: { code: normalized.code } }); + // 注意:ExpenseType.code 在数据库有唯一索引(全表唯一),唯一性检查必须与之一致, + // 否则软删行会让 DB 层抛重复键错误。软删后重建同名类型需另行放开唯一索引(需迁移)。 + const exists = await this.repo.findOne({ + where: { code: normalized.code }, + }); if (exists) throw new ConflictException('费用类型代码已存在'); return this.repo.save(this.repo.create(normalized)); } diff --git a/apps/server/src/financial-operations/financial-operations.service.ts b/apps/server/src/financial-operations/financial-operations.service.ts index 99c5964a..a5ce7fca 100644 --- a/apps/server/src/financial-operations/financial-operations.service.ts +++ b/apps/server/src/financial-operations/financial-operations.service.ts @@ -17,7 +17,7 @@ export class FinancialOperationsService { const existing = await this.repo.findOne({ where: { operationId } }); if (existing) { if (existing.type !== type) throw new ConflictException('operationId 已用于其他操作'); - if (existing.status === 'completed' && existing.resultJson) return JSON.parse(existing.resultJson) as T; + if (existing.status === 'completed' && existing.resultJson) return this.parseResult(existing.resultJson); if (existing.status === 'running') throw new ConflictException('该操作正在处理中,请勿重复提交'); } @@ -28,21 +28,38 @@ export class FinancialOperationsService { } catch (error) { const concurrent = await this.repo.findOne({ where: { operationId } }); if (concurrent?.status === 'completed' && concurrent.resultJson) { - return JSON.parse(concurrent.resultJson) as T; + return this.parseResult(concurrent.resultJson); } throw new ConflictException('该操作正在处理中,请勿重复提交', { cause: error }); } } else { + // 失败重试认领:条件状态更新,affected=1 才算认领成功, + // 避免并发下多个请求同时把 failed 改成 running 后重复执行 work() + const claim = await this.repo + .createQueryBuilder() + .update(FinancialOperation) + .set({ status: 'running', errorMessage: null, resultJson: null }) + .where('id = :id', { id: operation.id }) + .andWhere( + "(status IN ('failed') OR (status = 'completed' AND resultJson IS NULL) OR (status = 'running' AND updatedAt < DATE_SUB(NOW(), INTERVAL 15 MINUTE)))", + ) + .execute(); + if (claim.affected !== 1) { + const concurrent = await this.repo.findOne({ where: { operationId } }); + if (concurrent?.status === 'completed' && concurrent.resultJson) { + return this.parseResult(concurrent.resultJson); + } + throw new ConflictException('该操作正在处理中,请勿重复提交'); + } operation.status = 'running'; operation.errorMessage = null; operation.resultJson = null; - await this.repo.save(operation); } try { const result = await work(); operation.status = 'completed'; - operation.resultJson = JSON.stringify(result); + operation.resultJson = JSON.stringify(result ?? null); await this.repo.save(operation); return result; } catch (error) { @@ -52,4 +69,13 @@ export class FinancialOperationsService { throw error; } } + + /** 解析缓存的结果 JSON,损坏数据按失败处理而不是抛 500。 */ + private parseResult(resultJson: string | null | undefined): T { + try { + return JSON.parse(resultJson ?? 'null') as T; + } catch { + throw new ConflictException('该操作的结果数据已损坏,请重试'); + } + } } diff --git a/apps/server/src/imports/imports.commit.service.ts b/apps/server/src/imports/imports.commit.service.ts index 9a2be991..fb585d22 100644 --- a/apps/server/src/imports/imports.commit.service.ts +++ b/apps/server/src/imports/imports.commit.service.ts @@ -99,10 +99,28 @@ export class ImportCommitService { } } - run.status = 'committing'; - step.status = 'committing'; - await this.runs.save(run); - await this.steps.save(step); + // 原子抢占:只有 run/step 仍处于可提交状态时才迁移到 committing, + // 避免两个并发请求同时通过校验并重复写入(affected=0 表示已被他人抢占)。 + await this.dataSource.transaction(async (manager) => { + const runClaim = await manager.update( + ImportRun, + { id: run.id, status: In(['ready', 'preparing']) }, + { status: 'committing' }, + ); + if ((runClaim.affected ?? 0) !== 1) { + throw new ConflictException('导入任务正在提交中或状态已变化,请刷新后重试'); + } + const stepClaim = await manager.update( + ImportStep, + { id: step.id, status: 'ready' }, + { status: 'committing' }, + ); + if ((stepClaim.affected ?? 0) !== 1) { + throw new ConflictException( + `阶段「${IMPORT_STEP_LABELS[stepKey]}」正在提交中或状态已变化,请刷新后重试`, + ); + } + }); const counts = { created: 0, updated: 0, skipped: 0, failed: 0 }; try { diff --git a/apps/server/src/imports/imports.preview.service.ts b/apps/server/src/imports/imports.preview.service.ts index 1b278944..bc3d3520 100644 --- a/apps/server/src/imports/imports.preview.service.ts +++ b/apps/server/src/imports/imports.preview.service.ts @@ -80,7 +80,6 @@ export class ImportPreviewService { suggestMapping(sheetsData[0]?.headers ?? [], stepKey)); assertMapping(stepKey, mapping, sheetsData, usedSheets); - await this.rows.delete({ stepId: step.id }); const rowEntities: ImportRow[] = []; const summary: StepPreviewSummary = { total: 0, @@ -153,12 +152,17 @@ export class ImportPreviewService { } } - await this.rows.save(rowEntities); - step.sheetsJson = JSON.stringify(usedSheets); - step.mappingJson = JSON.stringify(mapping); - step.status = 'ready'; - step.summaryJson = JSON.stringify(summary); - await this.steps.save(step); + // 同一批次的写入(清空旧预览行、写新预览行、更新阶段状态)放进同一事务, + // 避免预览过程中失败留下半成品数据。 + await this.dataSource.transaction(async (manager) => { + await manager.delete(ImportRow, { stepId: step.id }); + await manager.save(ImportRow, rowEntities); + step.sheetsJson = JSON.stringify(usedSheets); + step.mappingJson = JSON.stringify(mapping); + step.status = 'ready'; + step.summaryJson = JSON.stringify(summary); + await manager.save(ImportStep, step); + }); const headers = sheetsData.find((s) => s.name === usedSheets[0])?.headers ?? []; const rows = rowEntities.map((entity) => ({ diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts index 6809fd5c..f321c48b 100644 --- a/apps/server/src/imports/imports.service.spec.ts +++ b/apps/server/src/imports/imports.service.spec.ts @@ -256,6 +256,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -316,6 +325,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -495,6 +513,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -559,6 +586,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -635,6 +671,15 @@ describe('ImportsService', () => { } return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -696,6 +741,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -767,6 +821,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -838,6 +901,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index d5254fda..e74977a2 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -54,6 +54,7 @@ function createCheckInManager(options?: { update: jest.fn(), }; const queryResults = [ + options?.student ?? { id: 3, organizationId: 7 }, options?.existingOccupancy ?? null, options?.room ?? { id: 2, capacity: 4, status: 'available' }, ...(options?.bed !== undefined ? [options.bed] : []), @@ -99,6 +100,7 @@ function createCheckOutManager(occupancy: Occupancy | null) { createQueryBuilder: jest.fn().mockReturnValue(createQueryBuilderMock(occupancy)), save: jest.fn(async (value) => value), update: jest.fn(), + count: jest.fn().mockResolvedValue(0), }; return manager; } diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 96717a20..0df6bbc5 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -72,6 +72,16 @@ export class OccupanciesService { async checkIn(dto: CheckInDto, userId?: number) { this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期'); return this.dataSource.transaction(async (manager) => { + // 先锁学生行:对「不存在的入住记录」做 SELECT ... FOR UPDATE 无法防止 + // 两个并发入住同时通过检查后插入重复记录;锁学生行可让同一学生的 + // 并发入住串行化,后到者在拿到锁后能看到先到者已提交的入住记录。 + const student = await this.withPessimisticWriteLock( + manager + .createQueryBuilder(Student, 'student') + .where('student.id = :studentId', { studentId: dto.studentId }), + ).getOne(); + if (!student) throw new NotFoundException('学生不存在'); + const existing = await this.withPessimisticWriteLock( manager .createQueryBuilder(Occupancy, 'occupancy') @@ -91,9 +101,6 @@ export class OccupanciesService { where: { roomId: dto.roomId, checkOutDate: IsNull() }, }); if (count >= (room.capacity ?? 0)) throw new BadRequestException('宿舍已满'); - const student = await manager.findOne(Student, { where: { id: dto.studentId } }); - if (!student) throw new NotFoundException('学生不存在'); - if (dto.bedId) { const bed = await this.withPessimisticWriteLock( manager.createQueryBuilder(Bed, 'bed').where('bed.id = :bedId AND bed.roomId = :roomId', { diff --git a/apps/server/src/occupancies/occupancy-operations.service.ts b/apps/server/src/occupancies/occupancy-operations.service.ts index 77896efe..d598c6bd 100644 --- a/apps/server/src/occupancies/occupancy-operations.service.ts +++ b/apps/server/src/occupancies/occupancy-operations.service.ts @@ -75,7 +75,14 @@ export class OccupancyOperationsService { await manager.save(occ); if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' }); if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' }); - await manager.update(Room, occ.roomId, { status: 'available' }); + // 多床位房间不能因为单条退宿就置为 available: + // 只有该房间不再有任何在住记录时才更新房间状态。 + const remainingActive = await manager.count(Occupancy, { + where: { roomId: occ.roomId, checkOutDate: IsNull() }, + }); + if (remainingActive === 0) { + await manager.update(Room, occ.roomId, { status: 'available' }); + } return occ; }); } @@ -113,7 +120,14 @@ export class OccupancyOperationsService { if (oldOcc.lockerId) { await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' }); } - await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); + // 多床位房间不能因为单次换房就置为 available: + // 只有原房间不再有任何在住记录时才更新房间状态。 + const oldRoomRemaining = await runner.manager.count(Occupancy, { + where: { roomId: oldOcc.roomId, checkOutDate: IsNull() }, + }); + if (oldRoomRemaining === 0) { + await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); + } // 检查新房容量 const newRoom = await withPessimisticWriteLock( runner.manager @@ -365,12 +379,18 @@ export class OccupancyOperationsService { occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; occ.checkOutReason = dto.checkOutReason || ''; await runner.manager.save(occ); - // 更新房间状态 - await runner.manager.update(Room, occ.roomId, { status: 'available' }); // 释放床位/柜子 if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' }); if (occ.lockerId) await runner.manager.update(Locker, occ.lockerId, { status: 'available' }); + // 多床位房间不能因为单条退宿就置为 available: + // 只有该房间不再有任何在住记录时才更新房间状态。 + const remainingActive = await runner.manager.count(Occupancy, { + where: { roomId: occ.roomId, checkOutDate: IsNull() }, + }); + if (remainingActive === 0) { + await runner.manager.update(Room, occ.roomId, { status: 'available' }); + } success++; } await runner.commitTransaction(); diff --git a/apps/server/src/rbac/rbac-presets.ts b/apps/server/src/rbac/rbac-presets.ts index e4b0ac0e..2a47c5bf 100644 --- a/apps/server/src/rbac/rbac-presets.ts +++ b/apps/server/src/rbac/rbac-presets.ts @@ -12,6 +12,9 @@ export const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: stri { code: 'student:import', name: '导入学生', group: 'student' }, { code: 'student:export', name: '导出学生', group: 'student' }, { code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' }, + { code: 'exam:create', name: '创建考试', group: 'exam' }, + { code: 'exam:edit', name: '编辑/归档考试与录入成绩', group: 'exam' }, + { code: 'exam:delete', name: '删除考试', group: 'exam' }, { code: 'room:view', name: '查看宿舍', group: 'room' }, { code: 'room:inspect', name: '宿舍查寝', group: 'room' }, { code: 'room:create', name: '新增宿舍', group: 'room' }, @@ -104,9 +107,6 @@ export const DEPRECATED_PERMISSION_CODES = [ 'learning:create', 'learning:edit', 'learning:delete', - 'exam:create', - 'exam:edit', - 'exam:delete', 'department:view', 'department:edit', 'department:delete', diff --git a/apps/server/src/rbac/rbac-seed.service.ts b/apps/server/src/rbac/rbac-seed.service.ts index 402081e9..29feb733 100644 --- a/apps/server/src/rbac/rbac-seed.service.ts +++ b/apps/server/src/rbac/rbac-seed.service.ts @@ -39,17 +39,6 @@ export class RbacService { return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] }); } - private async resolvePermissions(permissionIds: number[]): Promise { - const uniqueIds = [...new Set(permissionIds)]; - const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : []; - if (permissions.length !== uniqueIds.length) { - const foundIds = new Set(permissions.map((permission) => permission.id)); - const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); - throw new Error(`权限不存在: ${missingIds.join(',')}`); - } - return permissions; - } - async getTeacherWorkspace(userId: number) { // Find all classes where this user is a teacher const teacherAssignments = await this.classTeacherRepo.find({ @@ -139,11 +128,16 @@ export class RbacSeedService { } async seedData(): Promise { - const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true }); - if (restoredLegacyUsers.affected) { + // 仅当角色表为空(首次初始化)或显式设置 SEED_ROLES=true 时才播种; + // 否则跳过,避免每次启动都无条件重激活角色/权限,覆盖管理员的自定义调整。 + const forceSeed = process.env.SEED_ROLES === 'true'; + const roleCount = await this.roleRepo.count(); + if (roleCount > 0 && !forceSeed) { this.logger.log( - `已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`, + `角色表已有 ${roleCount} 条记录,跳过种子数据重激活(如需强制播种请设置 SEED_ROLES=true)`, ); + await this.ensureDefaultAdmin(); + return; } for (const p of PRESET_PERMISSIONS) { @@ -273,26 +267,40 @@ export class RbacSeedService { } } - const count = await this.userRepo.count(); - if (count === 0) { - const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; - const hash = await bcrypt.hash(adminPassword, 10); - const adminUser = this.userRepo.create({ - username: 'admin', - passwordHash: hash, - name: '管理员', - }); - const superAdminRole = allRoles.find((r) => r.code === 'super_admin'); - if (superAdminRole) { - adminUser.roles = [superAdminRole]; - } - await this.userRepo.save(adminUser); - this.logger.log( - `已创建默认管理员: admin / ${adminPassword === 'admin123' ? 'admin123 (请尽快修改!)' : '******'}`, - ); - } + await this.ensureDefaultAdmin(); this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`); } + /** + * 首次初始化时创建默认管理员。 + * 即使跳过角色/权限播种(角色表已有数据)也会执行,避免出现「有角色但无管理员」的状态。 + */ + private async ensureDefaultAdmin(): Promise { + const count = await this.userRepo.count(); + if (count > 0) return; + const adminPassword = process.env.ADMIN_PASSWORD; + // 生产环境禁止使用默认弱口令 + if (!adminPassword) { + if (process.env.NODE_ENV === 'production') { + throw new Error('生产环境必须设置 ADMIN_PASSWORD 后再初始化默认管理员'); + } + this.logger.warn('ADMIN_PASSWORD 未设置,开发环境使用默认密码 admin123'); + } + const password = adminPassword || 'admin123'; + const hash = await bcrypt.hash(password, 10); + const adminUser = this.userRepo.create({ + username: 'admin', + passwordHash: hash, + name: '管理员', + }); + const superAdminRole = await this.roleRepo.findOne({ where: { code: 'super_admin' } }); + if (superAdminRole) { + adminUser.roles = [superAdminRole]; + } + await this.userRepo.save(adminUser); + // 不打印密码,避免凭据落入日志 + this.logger.log('已创建默认管理员: admin(请妥善保管密码)'); + } + } diff --git a/apps/server/src/rbac/rbac.seed.spec.ts b/apps/server/src/rbac/rbac.seed.spec.ts index 42fef739..a75c2370 100644 --- a/apps/server/src/rbac/rbac.seed.spec.ts +++ b/apps/server/src/rbac/rbac.seed.spec.ts @@ -28,6 +28,7 @@ describe('RbacService seedData', () => { remove: jest.fn(async (value: any) => value), }; const roleRepo = { + count: jest.fn(async () => 0), findOne: jest.fn(async ({ where }: any) => where.code === 'system_admin' || where.name === '系统管理员' ? systemAdminRole : null, ), @@ -99,6 +100,7 @@ describe('RbacService seedData', () => { remove: jest.fn(async (value) => value), }; const roleRepo = { + count: jest.fn(async () => 0), findOne: jest.fn(async ({ where }: any) => where.code === 'teacher' || where.name === '老师' ? teacherRole : null, ), @@ -187,6 +189,7 @@ describe('RbacService legacy role consolidation', () => { remove: jest.fn(async (value) => value), }; const roleRepo = { + count: jest.fn(async () => 0), findOne: jest.fn(async () => targetRole), create: jest.fn((value) => ({ ...value, permissions: [] })), save: jest.fn(async (value) => value), @@ -217,3 +220,71 @@ describe('RbacService legacy role consolidation', () => { expect(roleRepo.remove).toHaveBeenCalledWith(legacyRole); }); }); + +describe('RbacSeedService startup gating', () => { + const makeService = (roleCount: number) => { + const permRepo = { + findOne: jest.fn(async () => null), + create: jest.fn(), + save: jest.fn(), + find: jest.fn(async () => []), + remove: jest.fn(), + }; + const roleRepo = { + count: jest.fn(async () => roleCount), + findOne: jest.fn(async () => null), + create: jest.fn(), + save: jest.fn(), + find: jest.fn(async () => []), + remove: jest.fn(), + }; + const userRepo = { + update: jest.fn(async () => ({ affected: 0 })), + count: jest.fn(), + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + }; + const service = new RbacService( + permRepo as never, + roleRepo as never, + userRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + return { service, permRepo, roleRepo, userRepo }; + }; + + it('skips re-seeding (no reactivation) when roles exist and SEED_ROLES is not set', async () => { + const original = process.env.SEED_ROLES; + delete process.env.SEED_ROLES; + try { + const { service, permRepo, roleRepo, userRepo } = makeService(3); + await service.seedData(); + expect(roleRepo.count).toHaveBeenCalled(); + expect(permRepo.findOne).not.toHaveBeenCalled(); + expect(roleRepo.save).not.toHaveBeenCalled(); + expect(userRepo.update).not.toHaveBeenCalled(); + } finally { + if (original === undefined) delete process.env.SEED_ROLES; + else process.env.SEED_ROLES = original; + } + }); + + it('seeds when SEED_ROLES=true even if roles already exist', async () => { + const original = process.env.SEED_ROLES; + process.env.SEED_ROLES = 'true'; + try { + const { service, permRepo, roleRepo } = makeService(3); + await service.seedData(); + expect(permRepo.findOne).toHaveBeenCalled(); + expect(roleRepo.save).toHaveBeenCalled(); + } finally { + if (original === undefined) delete process.env.SEED_ROLES; + else process.env.SEED_ROLES = original; + } + }); +}); diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index 5232bbde..6c353666 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -92,17 +92,22 @@ export class RoomsService { } async create(dto: CreateRoomDto) { - const parsed = RoomsService.parseRoomNumber(dto.roomNumber); - const entity = this.repo.create({ - ...dto, - building: dto.building ?? parsed.building, - floor: dto.floor ?? parsed.floor, - roomType: dto.roomType ?? parsed.roomType, - capacity: dto.capacity ?? parsed.capacity, + // 宿舍 + 默认床位创建放进同一事务,避免宿舍已建、床位失败留下半成品。 + return this.dataSource.transaction(async (manager) => { + const roomRepo = manager.getRepository(Room); + const bedRepo = manager.getRepository(Bed); + const parsed = RoomsService.parseRoomNumber(dto.roomNumber); + const entity = roomRepo.create({ + ...dto, + building: dto.building ?? parsed.building, + floor: dto.floor ?? parsed.floor, + roomType: dto.roomType ?? parsed.roomType, + capacity: dto.capacity ?? parsed.capacity, + }); + const room = await roomRepo.save(entity); + await this.createDefaultBedsWithManager(bedRepo, room.id, room.capacity); + return room; }); - const room = await this.repo.save(entity); - await this.createDefaultBeds(room.id, room.capacity); - return room; } async update(id: number, dto: UpdateRoomDto) { @@ -311,6 +316,19 @@ export class RoomsService { return this.queries.createDefaultBeds(roomId, capacity); } + private async createDefaultBedsWithManager( + bedRepo: Repository, + roomId: number, + capacity: number, + ): Promise { + const count = Math.max(capacity ?? 0, 0); + if (count === 0) return; + const beds = Array.from({ length: count }, (_, index) => + bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }), + ); + await bedRepo.save(beds); + } + private getNextBedNumber(beds: Pick[]): number { return this.bedOps.getNextBedNumber(beds); } diff --git a/apps/server/src/students/students.import.service.spec.ts b/apps/server/src/students/students.import.service.spec.ts new file mode 100644 index 00000000..3b87d410 --- /dev/null +++ b/apps/server/src/students/students.import.service.spec.ts @@ -0,0 +1,558 @@ +import { BadRequestException } from '@nestjs/common'; +import { EntityManager, Repository } from 'typeorm'; +import { StudentsImportService } from './students.import.service'; +import { Student } from '../entities/student.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { ExamScore } from '../entities/exam-score.entity'; +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; +import { Organization } from '../entities/organization.entity'; + +describe('StudentsImportService.batchImport', () => { + const makeManager = (options?: { + existingByPhone?: Record>; + existingByIdNumber?: Record>; + hostOrganization?: Organization | null; + throwOnOrganization?: boolean; + }): { + manager: EntityManager; + studentRepo: Repository; + organizationRepo: Repository; + archiveRepo: Record; + savedProfiles: any[]; + savedEnrollments: any[]; + savedExamScores: any[]; + savedLearningRecords: any[]; + } => { + const existingByPhone = options?.existingByPhone ?? {}; + const existingByIdNumber = options?.existingByIdNumber ?? {}; + const savedProfiles: any[] = []; + const savedEnrollments: any[] = []; + const savedExamScores: any[] = []; + const savedLearningRecords: any[] = []; + let nextStudentId = 1; + const inValues = (value: unknown): string[] => { + if (Array.isArray(value)) return value as string[]; + if (value && typeof value === 'object' && '_value' in value) { + return (value as { _value: unknown })._value as string[]; + } + return []; + }; + const studentRepo = { + // 批量预取后不再逐行调用 findOne:保留 mock 用于断言已被替代 + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn(async ({ where }: any) => { + if (where?.phone) { + return inValues(where.phone) + .map((phone) => existingByPhone[phone]) + .filter((student): student is Student => Boolean(student)); + } + if (where?.idNumber) { + return inValues(where.idNumber) + .map((idNumber) => existingByIdNumber[idNumber]) + .filter((student): student is Student => Boolean(student)); + } + return []; + }), + create: jest.fn((value: unknown) => value), + save: jest.fn(async (value: any) => { + const id = value?.id ?? nextStudentId++; + return { ...value, id }; + }), + update: jest.fn(), + } as unknown as Repository; + const organizationRepo = { + findOne: jest.fn(async () => { + if (options?.throwOnOrganization) throw new BadRequestException('尚未配置本机构'); + return options?.hostOrganization ?? { id: 7, isHost: true, status: 'active' }; + }), + } as unknown as Repository; + const archiveRepo = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((value: unknown) => value), + save: jest.fn(async (value: any) => { + savedProfiles.push(value); + return { ...value, id: value?.id ?? 10 }; + }), + }; + const manager = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return studentRepo; + if (entity === Organization) return organizationRepo; + if (entity === StudentProfile) return archiveRepo; + if (entity === ResultArchive) return archiveRepo; + if (entity === StudentEnrollment) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedEnrollments.push(v); + return { ...v, id: v?.id ?? 20 }; + }), + }; + } + if (entity === ExamScore) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedExamScores.push(v); + return { ...v, id: v?.id ?? 30 }; + }), + }; + } + if (entity === LearningRecord) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedLearningRecords.push(v); + return { ...v, id: v?.id ?? 40 }; + }), + }; + } + throw new Error(`Unexpected entity ${String(entity)}`); + }), + } as unknown as EntityManager; + return { + manager, + studentRepo, + organizationRepo, + archiveRepo, + savedProfiles, + savedEnrollments, + savedExamScores, + savedLearningRecords, + }; + }; + + const createService = (options?: Parameters[0]) => { + const mocks = makeManager(options); + const repo = { + manager: { + transaction: jest.fn(async (cb: (m: EntityManager) => unknown) => cb(mocks.manager)), + }, + } as unknown as Repository; + const service = new StudentsImportService(repo); + return { service, repo, ...mocks }; + }; + + it('imports students inside one transaction and returns the existing shape', async () => { + const { service, repo } = createService(); + const result = await service.batchImport([ + { name: '张三', phone: '13800138000' }, + { name: '李四', phone: '13900139000' }, + ]); + + expect(result).toEqual({ + message: '成功导入 2 名学生,导入档案相关记录 0 条,跳过 0 条(重复或空行)', + imported: 2, + archiveImported: 0, + skipped: 0, + invalidDateSkipped: 0, + }); + expect(repo.manager.transaction).toHaveBeenCalledTimes(1); + }); + + it('skips duplicate and empty rows without aborting the batch', async () => { + const { service } = createService({ + existingByPhone: { '13800138000': { id: 1, name: '张三', phone: '13800138000' } as Student }, + }); + const result = await service.batchImport([ + { name: '张三', phone: '13800138000' }, + { name: '', phone: '13900139000' }, + { name: '王五', phone: '13700137000' }, + ]); + + expect(result).toEqual({ + message: '成功导入 1 名学生,导入档案相关记录 0 条,跳过 2 条(重复或空行)', + imported: 1, + archiveImported: 0, + skipped: 2, + invalidDateSkipped: 0, + }); + }); + + it('keeps fail-fast semantics: a row failure rejects and rolls the batch back', async () => { + const { service, repo, manager } = createService({ throwOnOrganization: true }); + await expect( + service.batchImport([{ name: '张三', phone: '13800138000' }]), + ).rejects.toBeInstanceOf(BadRequestException); + // The transaction callback threw, so nothing was committed. + expect(repo.manager.transaction).toHaveBeenCalledTimes(1); + expect(manager.getRepository).toHaveBeenCalledWith(Student); + }); + + it('queries the host organization only once across the whole batch', async () => { + const { service, organizationRepo } = createService(); + const result = await service.batchImport([ + { name: '张三', phone: '13800138000' }, + { name: '李四', phone: '13900139000' }, + { name: '王五', phone: '13700137000' }, + ]); + + expect(result.imported).toBe(3); + expect(organizationRepo.findOne).toHaveBeenCalledTimes(1); + }); + + it('routes archive rows to the right student by phone', async () => { + const { service, savedEnrollments, savedExamScores, savedLearningRecords } = createService(); + const result = await service.batchImport({ + students: [ + { name: '张三', phone: '13800138000' }, + { name: '李四', phone: '13900139000' }, + ], + enrollments: [ + { phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' }, + { phone: '13900139000', courseCategory: 'art', classType: 'small_class', className: '周末班' }, + ], + examScores: [ + { phone: '13800138000', examType: 'monthly', subject: '语文', score: 90 }, + { phone: '13900139000', examType: 'final', subject: '数学', score: 88 }, + ], + learningRecords: [ + { phone: '13800138000', recordDate: '2024-10-20', recordType: 'study_feedback', content: 'ok' }, + { phone: '13900139000', recordDate: '2024-10-21', recordType: 'study_feedback', content: 'good' }, + ], + }); + + expect(result.imported).toBe(2); + expect(result.archiveImported).toBe(6); + expect(savedEnrollments.map((e) => e.studentId)).toEqual([1, 2]); + expect(savedExamScores.map((e) => e.studentId)).toEqual([1, 2]); + expect(savedLearningRecords.map((e) => e.studentId)).toEqual([1, 2]); + }); + + it('deduplicates identical enrollment rows within one import (in-memory upsert)', async () => { + const { service, savedEnrollments } = createService(); + await service.batchImport({ + students: [{ name: '张三', phone: '13800138000' }], + enrollments: [ + { phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' }, + { phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' }, + ], + examScores: [], + learningRecords: [], + }); + + // 同一行被命中两次并更新保存(与原有“find 后 save”的 upsert 语义一致), + // 但始终落在同一条记录上,不会新建重复行。 + expect(savedEnrollments).toHaveLength(2); + expect(new Set(savedEnrollments.map((e) => e.id)).size).toBe(1); + }); + + it('skips invalid dates, records the count, and still imports the rest of the row', async () => { + const { service, savedProfiles } = createService(); + const result = await service.batchImport({ + students: [ + { name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024/9/1' }, + ], + enrollments: [], + examScores: [ + { phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: 'not-a-date' }, + ], + learningRecords: [ + { phone: '13800138000', recordDate: '2024/10/20', recordType: 'study_feedback', content: 'ok' }, + ], + }); + + // profile + exam score imported(非法日期字段被跳过);learning record 因必填日期非法整条跳过 + expect(result.imported).toBe(1); + expect(result.archiveImported).toBe(2); + expect(result.invalidDateSkipped).toBe(3); + expect(savedProfiles[0]).not.toHaveProperty('profileDate'); + }); + + it('treats impossible calendar dates as invalid while accepting real leap days', async () => { + const { service, savedProfiles, savedExamScores } = createService(); + const result = await service.batchImport({ + students: [ + { name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024-02-31' }, + ], + enrollments: [], + examScores: [ + { phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: '2023-02-29' }, + { phone: '13800138000', examType: 'final', subject: '数学', score: 88, examDate: '2024-02-29' }, + ], + learningRecords: [], + }); + + // 2024-02-31 非法(2 月没有 31 号);2023 非闰年 2/29 非法;2024 闰年 2/29 合法 + expect(result.invalidDateSkipped).toBe(2); + expect(savedProfiles[0]).not.toHaveProperty('profileDate'); + expect(savedExamScores[0].examDate).toBeUndefined(); + expect(savedExamScores[1].examDate).toBe('2024-02-29'); + }); + + it('writes well-formed dates unchanged', async () => { + const { service, savedProfiles } = createService(); + await service.batchImport({ + students: [ + { name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024-09-01' }, + ], + enrollments: [], + examScores: [ + { phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: '2024-10-15' }, + ], + learningRecords: [ + { phone: '13800138000', recordDate: '2024-10-20', recordType: 'study_feedback', content: '状态稳定' }, + ], + }); + + expect(savedProfiles[0].profileDate).toBe('2024-09-01'); + }); + + it('prefetches existing students with two IN queries instead of per-row findOne', async () => { + const { service, studentRepo } = createService({ + existingByPhone: { '13800138000': { id: 1, name: '张三', phone: '13800138000' } as Student }, + existingByIdNumber: { + '110101199001011234': { id: 2, name: '李四', idNumber: '110101199001011234' } as Student, + }, + }); + const result = await service.batchImport([ + { name: '张三', phone: '13800138000' }, + { name: '李四', idNumber: '110101199001011234' }, + { name: '王五', phone: '13900139000' }, + ]); + + // 批量预取:phone + idNumber 各一次 IN 查询,不再逐行 findOne + expect(studentRepo.find).toHaveBeenCalledTimes(2); + expect(studentRepo.findOne).not.toHaveBeenCalled(); + expect(result.imported).toBe(1); + expect(result.skipped).toBe(2); + }); + + it('keeps in-batch dedup by phone/idNumber after saving a new student', async () => { + const { service, studentRepo } = createService(); + const result = await service.batchImport([ + { name: '张三', phone: '13800138000' }, + { name: '张三副本', phone: '13800138000' }, + { name: '李四', phone: '13900139000', idNumber: '110101199001011234' }, + { name: '李四副本', idNumber: '110101199001011234' }, + ]); + + // 新保存学生同步进内存索引:后续相同 phone/idNumber 行视为重复跳过 + expect(result.imported).toBe(2); + expect(result.skipped).toBe(2); + expect(studentRepo.findOne).not.toHaveBeenCalled(); + }); +}); + +describe('StudentsImportService.matchImport', () => { + const createService = (options?: { + existingByPhone?: Record>; + existingByIdNumber?: Record>; + }) => { + const existingByPhone = options?.existingByPhone ?? {}; + const existingByIdNumber = options?.existingByIdNumber ?? {}; + const savedProfiles: any[] = []; + const savedEnrollments: any[] = []; + const savedExamScores: any[] = []; + const savedLearningRecords: any[] = []; + const inValues = (value: unknown): string[] => { + if (Array.isArray(value)) return value as string[]; + if (value && typeof value === 'object' && '_value' in value) { + return (value as { _value: unknown })._value as string[]; + } + return []; + }; + const studentRepo = { + // 批量预取后不再逐行调用 findOne:保留 mock 用于断言已被替代 + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn(async ({ where }: any) => { + if (where?.phone) { + return inValues(where.phone) + .map((phone) => existingByPhone[phone]) + .filter((student): student is Student => Boolean(student)); + } + if (where?.idNumber) { + return inValues(where.idNumber) + .map((idNumber) => existingByIdNumber[idNumber]) + .filter((student): student is Student => Boolean(student)); + } + return []; + }), + update: jest.fn(), + } as unknown as Repository; + const archiveRepo = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((value: unknown) => value), + save: jest.fn(async (value: any) => { + savedProfiles.push(value); + return { ...value, id: value?.id ?? 10 }; + }), + }; + const manager = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return studentRepo; + if (entity === StudentProfile) return archiveRepo; + if (entity === ResultArchive) return archiveRepo; + if (entity === StudentEnrollment) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedEnrollments.push(v); + return { ...v, id: v?.id ?? 20 }; + }), + }; + } + if (entity === ExamScore) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedExamScores.push(v); + return { ...v, id: v?.id ?? 30 }; + }), + }; + } + if (entity === LearningRecord) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedLearningRecords.push(v); + return { ...v, id: v?.id ?? 40 }; + }), + }; + } + throw new Error(`Unexpected entity ${String(entity)}`); + }), + } as unknown as EntityManager; + const repo = { + manager: { + transaction: jest.fn(async (cb: (m: EntityManager) => unknown) => cb(manager)), + }, + } as unknown as Repository; + const service = new StudentsImportService(repo); + return { service, studentRepo, savedProfiles, savedEnrollments, savedExamScores, savedLearningRecords }; + }; + + it('updates a matched student with writable fields', async () => { + const { service, studentRepo } = createService({ + existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student }, + }); + const result = await service.matchImport([ + { phone: '13800138000', name: '张三', gender: '男' }, + ]); + + expect(studentRepo.update).toHaveBeenCalledWith(1, { name: '张三', gender: '男' }); + expect(result).toEqual({ + message: '更新已有学生资料 1 人,导入档案相关记录 0 条,跳过 0 条(无匹配/无更新字段/双键冲突)', + matched: 1, + archiveImported: 0, + skipped: 0, + skippedNoFields: 0, + skippedConflict: 0, + invalidDateSkipped: 0, + }); + }); + + it('skips matched rows without writable fields instead of calling an empty update', async () => { + const { service, studentRepo } = createService({ + existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student }, + }); + const result = await service.matchImport([{ phone: '13800138000' }]); + + expect(studentRepo.update).not.toHaveBeenCalled(); + expect(result).toEqual({ + message: '更新已有学生资料 0 人,导入档案相关记录 0 条,跳过 1 条(无匹配/无更新字段/双键冲突)', + matched: 0, + archiveImported: 0, + skipped: 1, + skippedNoFields: 1, + skippedConflict: 0, + invalidDateSkipped: 0, + }); + }); + + it('skips rows whose phone and idNumber match different students (conflict)', async () => { + const { service, studentRepo } = createService({ + existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student }, + existingByIdNumber: { '440111200001010011': { id: 2, idNumber: '440111200001010011' } as Student }, + }); + const result = await service.matchImport([ + { phone: '13800138000', idNumber: '440111200001010011', name: '张三' }, + ]); + + // 双键命中不同学生:不得更新任何一方,行计入 skippedConflict + expect(studentRepo.update).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + expect(result.skippedConflict).toBe(1); + expect(result.matched).toBe(0); + }); + + it('matches by idNumber and can still update phone when it differs', async () => { + const { service, studentRepo } = createService({ + existingByIdNumber: { '110101199001011234': { id: 1, idNumber: '110101199001011234', phone: '13800138000' } as Student }, + }); + const result = await service.matchImport([ + { idNumber: '110101199001011234', phone: '13900139000' }, + ]); + + expect(studentRepo.update).toHaveBeenCalledWith(1, { phone: '13900139000' }); + expect(result.matched).toBe(1); + expect(result.skippedNoFields).toBe(0); + }); + + it('imports archive rows for a matched student even when the student row has no fields', async () => { + const { service, studentRepo, savedEnrollments } = createService({ + existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student }, + }); + const result = await service.matchImport({ + students: [{ phone: '13800138000' }], + enrollments: [ + { phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' }, + ], + examScores: [], + learningRecords: [], + }); + + expect(studentRepo.update).not.toHaveBeenCalled(); + expect(savedEnrollments).toHaveLength(1); + expect(result.matched).toBe(1); + expect(result.archiveImported).toBe(1); + expect(result.skipped).toBe(0); + expect(result.skippedNoFields).toBe(0); + }); + + it('prefetches existing students with two IN queries instead of per-row findOne', async () => { + const { service, studentRepo } = createService({ + existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student }, + existingByIdNumber: { + '110101199001011234': { id: 2, idNumber: '110101199001011234' } as Student, + }, + }); + const result = await service.matchImport([ + { phone: '13800138000', name: '张三' }, + { idNumber: '110101199001011234', name: '李四' }, + { phone: '13900139000', name: '王五' }, + ]); + + // 批量预取:phone + idNumber 各一次 IN 查询,不再逐行 findOne + expect(studentRepo.find).toHaveBeenCalledTimes(2); + expect(studentRepo.findOne).not.toHaveBeenCalled(); + expect(result.matched).toBe(2); + expect(result.skipped).toBe(1); + }); + + it('keeps matching by updated phone within the same batch', async () => { + const { service, studentRepo } = createService({ + existingByIdNumber: { + '110101199001011234': { id: 1, idNumber: '110101199001011234', phone: '13800138000' } as Student, + }, + }); + const result = await service.matchImport([ + { idNumber: '110101199001011234', phone: '13900139000', name: '张三' }, + { phone: '13900139000', name: '张三2' }, + ]); + + // 第一行通过 idNumber 命中并把 phone 改为 13900139000, + // 第二行按更新后的 phone 仍能命中同一学生(与同一事务内 findOne 语义一致) + expect(studentRepo.update).toHaveBeenCalledTimes(2); + expect(result.matched).toBe(2); + expect(result.skipped).toBe(0); + }); +}); diff --git a/apps/server/src/students/students.import.service.ts b/apps/server/src/students/students.import.service.ts index ef89cf4c..d71b1b48 100644 --- a/apps/server/src/students/students.import.service.ts +++ b/apps/server/src/students/students.import.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, In, Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { StudentProfile } from '../entities/student-profile.entity'; import { StudentEnrollment } from '../entities/student-enrollment.entity'; @@ -16,115 +16,296 @@ import type { StudentWorkbookImport, } from './student-import'; import { getHostOrganizationId } from './students.organization'; +import { addDaysToDateOnly } from '../common/china-time'; + +/** 学生查重/匹配的内存索引:phone 与 idNumber 各一张 Map(key 为去空白后的值)。 */ +type StudentLookupIndex = { + byPhone: Map; + byIdNumber: Map; +}; @Injectable() export class StudentsImportService { + // 档案/报读/成绩等写入统一走 manager.getRepository(见下方 helpers), + // 这里只注入 Student repo(查重、更新、开启事务)。 constructor( @InjectRepository(Student) private readonly repo: Repository, - @InjectRepository(StudentProfile) private readonly profileRepo: Repository, - @InjectRepository(StudentEnrollment) - private readonly enrollmentRepo: Repository, - @InjectRepository(ExamScore) private readonly examScoreRepo: Repository, - @InjectRepository(LearningRecord) - private readonly learningRecordRepo: Repository, - @InjectRepository(ResultArchive) private readonly resultRepo: Repository, - @InjectRepository(Organization) private readonly organizationRepo: Repository, ) {} async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) { const data = this.normalizeImportData(importData); - let imported = 0; - let skipped = 0; - let archiveImported = 0; - for (const row of data.students) { - if (!row.name || !row.name.trim()) { - skipped++; - continue; + // 整批导入放进同一事务:查重、建学生、写档案要么全部成功,要么全部回滚。 + return this.repo.manager.transaction(async (manager) => { + const studentRepo = manager.getRepository(Student); + const organizationRepo = manager.getRepository(Organization); + // 按手机号预建报读/成绩/回访行分组(手机号是导入行与学生档案之间的关联键, + // 等价于按学生分组),避免逐学生循环内对 data.* 全量 filter。 + const enrollmentsByPhone = this.groupByPhone(data.enrollments); + const examScoresByPhone = this.groupByPhone(data.examScores); + const learningRecordsByPhone = this.groupByPhone(data.learningRecords); + // 循环外按本批所有 phone/idNumber 一次性批量预取已有学生(仅两条 IN 查询), + // 内存建索引后逐行匹配,避免逐行 findOne 的 N+1。 + const existingIndexes = await this.prefetchStudentIndexes(studentRepo, data.students); + let imported = 0; + let skipped = 0; + let archiveImported = 0; + let invalidDateSkipped = 0; + // 本机构 id 循环外懒加载缓存一次,避免每个学生行都查询 + let hostOrganizationId: number | undefined; + for (const row of data.students) { + if (!row.name || !row.name.trim()) { + skipped++; + continue; + } + // 按 phone/idNumber 查重:先 phone 后 idNumber,命中即视为已存在 + if (this.matchStudentByIndexes(existingIndexes, row)) { + skipped++; + continue; + } + if (!row.organizationId && hostOrganizationId === undefined) { + hostOrganizationId = await getHostOrganizationId(organizationRepo); + } + const student = await studentRepo.save( + studentRepo.create({ + name: row.name.trim(), + studentNo: row.studentNo?.trim() || undefined, + phone: row.phone?.trim() || undefined, + idNumber: row.idNumber?.trim() || undefined, + gender: row.gender || undefined, + ethnicity: row.ethnicity || undefined, + emergencyContact: row.emergencyContact || undefined, + emergencyPhone: row.emergencyPhone || undefined, + supervisor: row.supervisor || undefined, + organizationId: row.organizationId || hostOrganizationId, + }), + ); + // 新保存的学生同步进内存索引:本批后续相同 phone/idNumber 行视为重复跳过, + // 保持与逐行 findOne 一致的 upsert/去重语义。 + this.indexStudent(existingIndexes, student); + const archive = await this.importArchiveData(manager, student.id, row, { + enrollments: enrollmentsByPhone, + examScores: examScoresByPhone, + learningRecords: learningRecordsByPhone, + }); + archiveImported += archive.imported; + invalidDateSkipped += archive.invalidDateSkipped; + imported++; } - const exists = await this.repo.findOne({ where: { name: row.name.trim() } }); - if (exists) { - skipped++; - continue; - } - const student = await this.repo.save( - this.repo.create({ - name: row.name.trim(), - studentNo: row.studentNo?.trim() || undefined, - phone: row.phone?.trim() || undefined, - idNumber: row.idNumber?.trim() || undefined, - gender: row.gender || undefined, - ethnicity: row.ethnicity || undefined, - emergencyContact: row.emergencyContact || undefined, - emergencyPhone: row.emergencyPhone || undefined, - supervisor: row.supervisor || undefined, - organizationId: row.organizationId || (await getHostOrganizationId(this.organizationRepo)), - }), - ); - archiveImported += await this.importArchiveData(student.id, row, data); - imported++; - } - return { - message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, - imported, - archiveImported, - skipped, - }; + return { + message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, + imported, + archiveImported, + skipped, + invalidDateSkipped, + }; + }); } async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) { const data = this.normalizeImportData(importData); - let matched = 0; - let skipped = 0; - let archiveImported = 0; - for (const row of data.students) { - // Match by phone first, then idNumber - let student = row.phone?.trim() - ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) - : null; - if (!student && row.idNumber?.trim()) { - student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } }); + // 整批匹配写入放进同一事务:更新学生资料与写档案要么全部成功,要么全部回滚。 + return this.repo.manager.transaction(async (manager) => { + const studentRepo = manager.getRepository(Student); + // 与 batchImport 一致:按手机号预建分组,避免逐学生循环内全量 filter。 + const enrollmentsByPhone = this.groupByPhone(data.enrollments); + const examScoresByPhone = this.groupByPhone(data.examScores); + const learningRecordsByPhone = this.groupByPhone(data.learningRecords); + // 循环外按本批所有 phone/idNumber 一次性批量预取已有学生(仅两条 IN 查询), + // 内存建索引后逐行匹配,避免逐行 findOne 的 N+1。 + const existingIndexes = await this.prefetchStudentIndexes(studentRepo, data.students); + let matched = 0; + let skipped = 0; + let skippedNoFields = 0; + let skippedConflict = 0; + let archiveImported = 0; + let invalidDateSkipped = 0; + for (const row of data.students) { + // 双键命中不同学生:跳过该行,避免把 idNumber 写到错误的 student 上 + if (this.hasIndexConflict(existingIndexes, row)) { + skipped++; + skippedConflict++; + continue; + } + // Match by phone first, then idNumber + const student = this.matchStudentByIndexes(existingIndexes, row); + if (!student) { + skipped++; + continue; + } + const updates: Partial< + Pick< + Student, + | 'name' + | 'studentNo' + | 'phone' + | 'idNumber' + | 'gender' + | 'ethnicity' + | 'emergencyContact' + | 'emergencyPhone' + | 'supervisor' + | 'organizationId' + > + > = {}; + if (row.name?.trim()) updates.name = row.name.trim(); + if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); + // phone/idNumber 是匹配键:与命中学生一致时视为无变更(避免空 set 更新), + // 不一致时仍允许通过另一个键命中后更新。 + if (row.phone?.trim() && row.phone.trim() !== student.phone) updates.phone = row.phone.trim(); + if (row.idNumber?.trim() && row.idNumber.trim() !== student.idNumber) updates.idNumber = row.idNumber.trim(); + if (row.gender) updates.gender = row.gender; + if (row.ethnicity) updates.ethnicity = row.ethnicity; + if (row.emergencyContact) updates.emergencyContact = row.emergencyContact; + if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; + if (row.supervisor) updates.supervisor = row.supervisor; + if (row.organizationId) updates.organizationId = row.organizationId; + // 按 phone/idNumber 命中已有学生但本行没有任何可写字段时, + // 跳过该学生的更新(避免 TypeORM 对空 set 报错),保留匹配/去重语义。 + const hasWritableFields = Object.keys(updates).length > 0; + const archive = await this.importArchiveData(manager, student.id, row, { + enrollments: enrollmentsByPhone, + examScores: examScoresByPhone, + learningRecords: learningRecordsByPhone, + }); + archiveImported += archive.imported; + invalidDateSkipped += archive.invalidDateSkipped; + if (!hasWritableFields && archive.imported === 0 && archive.invalidDateSkipped === 0) { + // 整行无任何可写字段:计入 skipped(不抛错) + skippedNoFields++; + skipped++; + continue; + } + if (hasWritableFields) { + await studentRepo.update(student.id, updates); + // 更新后的 phone/idNumber 同步进内存索引:后续行按新值仍可命中, + // 与逐行 findOne 在同一事务内能看到本批已更新记录的语义一致。 + this.reindexStudent(existingIndexes, student, updates); + } + matched++; } - if (!student) { - skipped++; - continue; - } - const updates: Partial< - Pick< - Student, - | 'name' - | 'studentNo' - | 'phone' - | 'idNumber' - | 'gender' - | 'ethnicity' - | 'emergencyContact' - | 'emergencyPhone' - | 'supervisor' - | 'organizationId' - > - > = {}; - if (row.name?.trim()) updates.name = row.name.trim(); - if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); - if (row.phone?.trim()) updates.phone = row.phone.trim(); - if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); - if (row.gender) updates.gender = row.gender; - if (row.ethnicity) updates.ethnicity = row.ethnicity; - if (row.emergencyContact) updates.emergencyContact = row.emergencyContact; - if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; - if (row.supervisor) updates.supervisor = row.supervisor; - if (row.organizationId) updates.organizationId = row.organizationId; - await this.repo.update(student.id, updates); - archiveImported += await this.importArchiveData(student.id, row, data); - matched++; - } + return { + message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配/无更新字段/双键冲突)`, + matched, + archiveImported, + skipped, + skippedNoFields, + skippedConflict, + invalidDateSkipped, + }; + }); + } + + /** + * 批量预取本批导入行涉及的所有 phone / idNumber 对应的已有学生: + * 只发两条 IN 查询(phone In(...)、idNumber In(...)),内存建索引供逐行匹配, + * 替代逐行 findOne(N+1)。某类 key 为空时跳过对应查询。 + */ + private async prefetchStudentIndexes( + studentRepo: Repository, + rows: StudentImportRow[], + ): Promise { + const phones = [ + ...new Set( + rows + .map((row) => row.phone?.trim()) + .filter((phone): phone is string => Boolean(phone)), + ), + ]; + const idNumbers = [ + ...new Set( + rows + .map((row) => row.idNumber?.trim()) + .filter((idNumber): idNumber is string => Boolean(idNumber)), + ), + ]; + const [byPhoneList, byIdNumberList] = await Promise.all([ + phones.length > 0 + ? studentRepo.find({ where: { phone: In(phones) } }) + : Promise.resolve([] as Student[]), + idNumbers.length > 0 + ? studentRepo.find({ where: { idNumber: In(idNumbers) } }) + : Promise.resolve([] as Student[]), + ]); return { - message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`, - matched, - archiveImported, - skipped, + byPhone: this.buildIndex(byPhoneList, (student) => student.phone), + byIdNumber: this.buildIndex(byIdNumberList, (student) => student.idNumber), }; } + /** 从查询结果构建 phone / idNumber → Student 的 Map(key 去空白,空值不建索引)。 */ + private buildIndex( + students: Student[], + keyOf: (student: Student) => string | null | undefined, + ): Map { + const index = new Map(); + for (const student of students) { + const key = keyOf(student)?.trim(); + if (key) index.set(key, student); + } + return index; + } + + /** + * 双键冲突检测:同一行 phone 与 idNumber 分别命中不同的学生时,不能更新任何一个, + * 否则会把一个学生的 idNumber 写到另一个学生身上(数据错乱)。冲突行按 skipped 处理。 + */ + private hasIndexConflict(indexes: StudentLookupIndex, row: StudentImportRow): boolean { + const phone = row.phone?.trim(); + const idNumber = row.idNumber?.trim(); + if (!phone || !idNumber) return false; + const byPhone = indexes.byPhone.get(phone); + const byIdNumber = indexes.byIdNumber.get(idNumber); + return !!byPhone && !!byIdNumber && byPhone.id !== byIdNumber.id; + } + + /** 逐行匹配已有学生:优先 phone,未命中再按 idNumber(与 matchImport 原有语义一致)。 */ + private matchStudentByIndexes( + indexes: StudentLookupIndex, + row: StudentImportRow, + ): Student | undefined { + const phone = row.phone?.trim(); + if (phone) { + const byPhone = indexes.byPhone.get(phone); + if (byPhone) return byPhone; + } + const idNumber = row.idNumber?.trim(); + if (idNumber) { + const byIdNumber = indexes.byIdNumber.get(idNumber); + if (byIdNumber) return byIdNumber; + } + return undefined; + } + + /** 新保存的学生同步进内存索引(batchImport 批次内去重)。 */ + private indexStudent(indexes: StudentLookupIndex, student: Student): void { + if (student.phone) indexes.byPhone.set(student.phone.trim(), student); + if (student.idNumber) indexes.byIdNumber.set(student.idNumber.trim(), student); + } + + /** + * matchImport 更新学生资料后同步内存索引:phone/idNumber 被改写时删除旧 key、 + * 注册新 key,并让内存实体与已更新值保持一致(后续行按新值仍可命中)。 + */ + private reindexStudent( + indexes: StudentLookupIndex, + student: Student, + updates: Partial>, + ): void { + const oldPhone = student.phone?.trim(); + const newPhone = updates.phone?.trim(); + const oldIdNumber = student.idNumber?.trim(); + const newIdNumber = updates.idNumber?.trim(); + Object.assign(student, updates); + if (newPhone && newPhone !== oldPhone) { + if (oldPhone) indexes.byPhone.delete(oldPhone); + indexes.byPhone.set(newPhone, student); + } + if (newIdNumber && newIdNumber !== oldIdNumber) { + if (oldIdNumber) indexes.byIdNumber.delete(oldIdNumber); + indexes.byIdNumber.set(newIdNumber, student); + } + } + private normalizeImportData( importData: StudentWorkbookImport | StudentImportRow[], ): StudentWorkbookImport { @@ -142,6 +323,30 @@ export class StudentsImportService { return String(left ?? '').trim() === String(right ?? '').trim(); } + /** 按手机号预建行分组(手机号去空白后作为 key,空手机号的行不参与匹配)。 */ + private groupByPhone(rows: T[]): Map { + const byPhone = new Map(); + for (const row of rows) { + const phone = this.normalizePhone(row.phone); + if (!phone) continue; + const list = byPhone.get(phone); + if (list) list.push(row); + else byPhone.set(phone, [row]); + } + return byPhone; + } + + /** + * 日期字段写入前的真实日历校验:先检查 YYYY-MM-DD 形状, + * 再用 common/china-time 的 addDaysToDateOnly 往返判断月/日是否真实存在 + * (Date.UTC 会把溢出日期归一化,如 2024-02-31 → 2024-03-02,往返不一致即非法)。 + */ + private isValidDateOnly(value: string): boolean { + const dateOnly = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateOnly)) return false; + return addDaysToDateOnly(dateOnly, 0) === dateOnly; + } + private hasProfileData(row: StudentImportRow) { return [ row.targetCollege, @@ -166,89 +371,161 @@ export class StudentsImportService { } private async importArchiveData( + manager: EntityManager, studentId: number, row: StudentImportRow, - data: StudentWorkbookImport, - ) { + data: { + enrollments: Map; + examScores: Map; + learningRecords: Map; + }, + ): Promise<{ imported: number; invalidDateSkipped: number }> { const phone = this.normalizePhone(row.phone); let imported = 0; + let invalidDateSkipped = 0; if (this.hasProfileData(row)) { - await this.upsertProfileFromImport(studentId, row); + // 非法日期不写入,跳过该字段并计数 + if (row.profileDate?.trim() && !this.isValidDateOnly(row.profileDate)) { + invalidDateSkipped++; + } + await this.upsertProfileFromImport(manager, studentId, row); imported++; } if (this.hasResultData(row)) { - await this.upsertResultFromImport(studentId, row); + await this.upsertResultFromImport(manager, studentId, row); imported++; } - if (!phone) return imported; + if (!phone) return { imported, invalidDateSkipped }; + + const enrollmentRows = data.enrollments.get(phone) ?? []; + const examRows = data.examScores.get(phone) ?? []; + const learningRows = data.learningRecords.get(phone) ?? []; + + // 按 studentId 批量预取既有档案行,内存匹配(保持 upsert 语义), + // 避免每个档案行单独 find 查询;新建保存后同步加入内存列表供后续行匹配。 + let existingEnrollments: StudentEnrollment[] = []; + let existingExamScores: ExamScore[] = []; + let existingLearningRecords: LearningRecord[] = []; + if (enrollmentRows.length > 0) { + existingEnrollments = await manager.getRepository(StudentEnrollment).find({ + where: { studentId }, + }); + } + if (examRows.length > 0) { + existingExamScores = await manager.getRepository(ExamScore).find({ + where: { studentId }, + }); + } + if (learningRows.length > 0) { + existingLearningRecords = await manager.getRepository(LearningRecord).find({ + where: { studentId }, + }); + } const enrollmentByClassName = new Map(); - for (const enrollmentRow of data.enrollments.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { - const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); + for (const enrollmentRow of enrollmentRows) { + const enrollment = await this.upsertEnrollmentFromImport( + manager, + studentId, + enrollmentRow, + existingEnrollments, + ); if (!enrollment) continue; if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); imported++; } - for (const examRow of data.examScores.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { - if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { + for (const examRow of examRows) { + // 非法日期不写入,跳过该字段并计数 + if (examRow.examDate?.trim() && !this.isValidDateOnly(examRow.examDate)) { + invalidDateSkipped++; + } + if ( + await this.upsertExamScoreFromImport( + manager, + studentId, + examRow, + enrollmentByClassName, + existingExamScores, + ) + ) { imported++; } } - for (const learningRow of data.learningRecords.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { - if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { + for (const learningRow of learningRows) { + // recordDate 必填:非法日期视为缺省,跳过整条并计数 + if (learningRow.recordDate?.trim() && !this.isValidDateOnly(learningRow.recordDate)) { + invalidDateSkipped++; + continue; + } + if (await this.upsertLearningRecordFromImport(manager, studentId, learningRow, existingLearningRecords)) { imported++; } } - return imported; + return { imported, invalidDateSkipped }; } - private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { + private async upsertProfileFromImport( + manager: EntityManager, + studentId: number, + row: StudentImportRow, + ) { + const profileRepo = manager.getRepository(StudentProfile); const entity = - (await this.profileRepo.findOne({ where: { studentId } })) || - this.profileRepo.create({ studentId }); + (await profileRepo.findOne({ where: { studentId } })) || + profileRepo.create({ studentId }); if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim(); if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim(); if (row.grade?.trim()) entity.grade = row.grade.trim(); - if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim(); + if (row.profileDate?.trim() && this.isValidDateOnly(row.profileDate)) { + entity.profileDate = row.profileDate.trim(); + } if (row.notes?.trim()) entity.notes = row.notes.trim(); - await this.profileRepo.save(entity); + await profileRepo.save(entity); } - private async upsertResultFromImport(studentId: number, row: StudentImportRow) { + private async upsertResultFromImport( + manager: EntityManager, + studentId: number, + row: StudentImportRow, + ) { + const resultRepo = manager.getRepository(ResultArchive); const entity = - (await this.resultRepo.findOne({ where: { studentId } })) || - this.resultRepo.create({ studentId }); + (await resultRepo.findOne({ where: { studentId } })) || + resultRepo.create({ studentId }); if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore; if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); - await this.resultRepo.save(entity); + await resultRepo.save(entity); } - private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) { + private async upsertEnrollmentFromImport( + manager: EntityManager, + studentId: number, + row: StudentEnrollmentImportRow, + existing: StudentEnrollment[], + ) { if (!row.courseCategory?.trim() || !row.classType?.trim()) { return null; } - const existing = await this.enrollmentRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.courseCategory, row.courseCategory) && - this.sameValue(item.classType, row.classType) && - this.sameValue(item.className, row.className) && - this.sameValue(item.startDate, row.startDate), - ) || this.enrollmentRepo.create({ studentId }); + const enrollmentRepo = manager.getRepository(StudentEnrollment); + let entity = existing.find( + (item) => + this.sameValue(item.courseCategory, row.courseCategory) && + this.sameValue(item.classType, row.classType) && + this.sameValue(item.className, row.className) && + this.sameValue(item.startDate, row.startDate), + ); + if (!entity) { + entity = enrollmentRepo.create({ studentId }); + // 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体 + existing.push(entity); + } entity.courseCategory = row.courseCategory.trim(); entity.classType = row.classType.trim(); if (row.className?.trim()) entity.className = row.className.trim(); @@ -258,57 +535,74 @@ export class StudentsImportService { if (row.endDate?.trim()) entity.endDate = row.endDate.trim(); if (row.status?.trim()) entity.status = row.status.trim(); else if (!entity.status) entity.status = 'active'; - return this.enrollmentRepo.save(entity); + return enrollmentRepo.save(entity); } private async upsertExamScoreFromImport( + manager: EntityManager, studentId: number, row: ExamScoreImportRow, enrollmentByClassName: Map, + existing: ExamScore[], ) { if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false; - const existing = await this.examScoreRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.examType, row.examType) && - this.sameValue(item.examName, row.examName) && - this.sameValue(item.subject, row.subject) && - this.sameValue(item.examDate, row.examDate), - ) || this.examScoreRepo.create({ studentId }); + const examScoreRepo = manager.getRepository(ExamScore); + let entity = existing.find( + (item) => + this.sameValue(item.examType, row.examType) && + this.sameValue(item.examName, row.examName) && + this.sameValue(item.subject, row.subject) && + this.sameValue(item.examDate, row.examDate), + ); + if (!entity) { + entity = examScoreRepo.create({ studentId }); + // 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体 + existing.push(entity); + } entity.examType = row.examType.trim(); entity.subject = row.subject.trim(); entity.score = row.score; if (row.examName?.trim()) entity.examName = row.examName.trim(); if (row.classAvg !== undefined) entity.classAvg = row.classAvg; if (row.rank !== undefined) entity.rank = row.rank; - if (row.examDate?.trim()) entity.examDate = row.examDate.trim(); + if (row.examDate?.trim() && this.isValidDateOnly(row.examDate)) { + entity.examDate = row.examDate.trim(); + } if (row.enrollmentName?.trim()) { const enrollment = enrollmentByClassName.get(row.enrollmentName.trim()); if (enrollment) entity.enrollmentId = enrollment.id; } if (!entity.status) entity.status = 'active'; - await this.examScoreRepo.save(entity); + await examScoreRepo.save(entity); return true; } - private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) { + private async upsertLearningRecordFromImport( + manager: EntityManager, + studentId: number, + row: LearningRecordImportRow, + existing: LearningRecord[], + ) { if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false; - const existing = await this.learningRecordRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.recordDate, row.recordDate) && - this.sameValue(item.recordType, row.recordType) && - this.sameValue(item.content, row.content), - ) || this.learningRecordRepo.create({ studentId }); + const learningRecordRepo = manager.getRepository(LearningRecord); + let entity = existing.find( + (item) => + this.sameValue(item.recordDate, row.recordDate) && + this.sameValue(item.recordType, row.recordType) && + this.sameValue(item.content, row.content), + ); + if (!entity) { + entity = learningRecordRepo.create({ studentId }); + // 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体 + existing.push(entity); + } entity.recordDate = row.recordDate.trim(); entity.recordType = row.recordType.trim(); entity.content = row.content.trim(); if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim(); if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim(); if (!entity.status) entity.status = 'active'; - await this.learningRecordRepo.save(entity); + await learningRecordRepo.save(entity); return true; } } diff --git a/apps/server/src/sync/schedule-sync.service.ts b/apps/server/src/sync/schedule-sync.service.ts index 837b3114..95953fa6 100644 --- a/apps/server/src/sync/schedule-sync.service.ts +++ b/apps/server/src/sync/schedule-sync.service.ts @@ -77,6 +77,13 @@ export class ScheduleSyncService { const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds); const classNameMap = await loadClassNames(this.classRepo, classIds); + // 同名班级会串组:统计本次参与同步的班级名出现次数, + // 重名时在考勤组名/组标识中带上 classId 以避免共享同一个考勤组。 + const classNameCounts = new Map(); + for (const name of classNameMap.values()) { + classNameCounts.set(name, (classNameCounts.get(name) || 0) + 1); + } + // ── Step 3: 将每天的多节课合并成一个钉钉班次 ── // 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为 // 同一个班次的多个 sections 写入,不能拆成多条 schedule item。 @@ -203,8 +210,11 @@ export class ScheduleSyncService { continue; } - // 创建/匹配该班级的考勤组 - const groupName = `排课_${className}`; + // 创建/匹配该班级的考勤组;重名班级时追加 classId 后缀避免串组 + const groupName = + (classNameCounts.get(className) ?? 0) > 1 + ? `排课_${classId}_${className}` + : `排课_${className}`; let attendanceGroupId: number; try { const cached = groupByName.get(groupName); diff --git a/apps/server/src/sync/sync-runner.ts b/apps/server/src/sync/sync-runner.ts index 38893d27..d06326b0 100644 --- a/apps/server/src/sync/sync-runner.ts +++ b/apps/server/src/sync/sync-runner.ts @@ -41,7 +41,15 @@ export class SyncRunner { this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined); throw error; } finally { - await this.releaseLease(platform, runId); + // releaseLease 自身兜底:释放租约失败不能掩盖原始的同步结果/错误 + try { + await this.releaseLease(platform, runId); + } catch (error) { + this.logger.error( + `${platform} sync lease release failed`, + error instanceof Error ? error.stack : undefined, + ); + } } } diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index 7f609ba7..1f03d9fc 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -189,6 +189,7 @@ export class SyncService { let matched = 0; let created = 0; + let skippedNoFields = 0; await this.dataSource.transaction(async (manager) => { const orgs = await manager.query>( 'SELECT id FROM organizations WHERE is_host = 1 AND status = ? LIMIT 1', @@ -221,6 +222,11 @@ export class SyncService { ); if (decision.action === 'match' && decision.matchStudentId) { + // 所有映射字段均为空时跳过更新,避免用空对象覆盖已有学生数据。 + if (Object.keys(mappedValues).length === 0) { + skippedNoFields++; + continue; + } await manager.update(Student, decision.matchStudentId, mappedValues); matched++; } else if (decision.action === 'create') { @@ -242,7 +248,10 @@ export class SyncService { return { recordsCount: matched + created, status: 'success', - message: `匹配 ${matched} 人,新增 ${created} 人`, + message: + skippedNoFields > 0 + ? `匹配 ${matched} 人,新增 ${created} 人,跳过无更新字段 ${skippedNoFields} 条` + : `匹配 ${matched} 人,新增 ${created} 人`, }; }); } diff --git a/apps/server/src/wallets/wallets.service.spec.ts b/apps/server/src/wallets/wallets.service.spec.ts index 740aac22..a0e9e55c 100644 --- a/apps/server/src/wallets/wallets.service.spec.ts +++ b/apps/server/src/wallets/wallets.service.spec.ts @@ -5,15 +5,29 @@ import { Bill } from '../entities/bill.entity'; const manager = (walletBalance: number) => { const wallet = { id: 1, studentId: 10, balance: walletBalance }; const saved: any[] = []; + const updateQuery = { + update: jest.fn(), + set: jest.fn(), + setParameter: jest.fn(), + where: jest.fn(), + andWhere: jest.fn(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + }; + updateQuery.update.mockReturnValue(updateQuery); + updateQuery.set.mockReturnValue(updateQuery); + updateQuery.setParameter.mockReturnValue(updateQuery); + updateQuery.where.mockReturnValue(updateQuery); + updateQuery.andWhere.mockReturnValue(updateQuery); return { wallet, saved, + updateQuery, value: { findOne: jest.fn(async () => wallet), findOneByOrFail: jest.fn(async () => wallet), save: jest.fn(async (value: any) => { saved.push(value); return value; }), create: jest.fn((_entity: unknown, value: unknown) => value), - createQueryBuilder: jest.fn(), + createQueryBuilder: jest.fn(() => updateQuery), }, }; }; @@ -91,6 +105,8 @@ describe('WalletsService financial boundaries', () => { it('does not issue a second refund for an already cancelled bill', async () => { const ctx = manager(10); + // 模拟条件 UPDATE(status <> 'cancelled')未命中:已取消账单不产生任何退款 + ctx.updateQuery.execute.mockResolvedValueOnce({ affected: 0 }); const service = new WalletsService({} as any, {} as any, {} as any, {} as any); const bill = { id: 12, @@ -107,6 +123,25 @@ describe('WalletsService financial boundaries', () => { expect(ctx.saved).toHaveLength(0); }); + it('throws 余额不足 when a concurrent debit drained the wallet', async () => { + const ctx = manager(40); + const service = new WalletsService({} as any, {} as any, {} as any, {} as any); + const bill = { + id: 13, + studentId: 10, + totalAmount: 100, + paidAmount: 0, + outstandingAmount: 100, + status: 'unpaid', + } as Bill; + ctx.updateQuery.execute.mockResolvedValue({ affected: 0 }); + + await expect(service.debitBill(ctx.value as any, bill, 1)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(ctx.saved.some((row) => row.type === 'bill_payment')).toBe(false); + }); + it('rejects an amount that rounds to zero before opening a transaction', async () => { const dataSource = { transaction: jest.fn() }; const service = new WalletsService({} as any, {} as any, {} as any, dataSource as any); @@ -152,3 +187,54 @@ describe('WalletsService wallet locking', () => { expect(ctx.query.setLock).toHaveBeenCalledWith('pessimistic_write'); }); }); + +describe('WalletsService transaction history', () => { + it('limits transactions to the latest 200 rows', async () => { + const transactionRepo = { find: jest.fn().mockResolvedValue([]) }; + const service = new WalletsService({} as any, transactionRepo as any, {} as any, {} as any); + + await service.findTransactions(10); + + expect(transactionRepo.find).toHaveBeenCalledWith({ + where: { studentId: 10 }, + order: { createdAt: 'DESC' }, + take: 200, + }); + }); +}); + +describe('WalletsService batch operation key length', () => { + it('caps operation_id at 64 chars while keeping the :studentId suffix', async () => { + const wallet = { id: 1, studentId: 10, balance: 2 }; + const saved: any[] = []; + const txManager = { + findOne: jest.fn(async () => wallet), + findOneByOrFail: jest.fn(async () => wallet), + save: jest.fn(async (value: any) => { saved.push(value); return value; }), + create: jest.fn((_entity: unknown, value: unknown) => value), + }; + const dataSource = { + transaction: jest.fn(async (work: (manager: any) => Promise) => work(txManager)), + }; + const service = new WalletsService( + {} as any, + {} as any, + { findOne: jest.fn(async () => ({ id: 10 })) } as any, + dataSource as any, + ); + const longId = 'x'.repeat(70); + + await service.batchChangeBalance({ + operationId: longId, + studentIds: [10], + amount: -1, + type: 'adjustment', + description: '批量调账', + }); + + const tx = saved.find((row: any) => row.type === 'adjustment'); + expect(tx.operationId).toBe(`${longId.slice(0, 50)}:10`); + expect(tx.operationId.length).toBeLessThanOrEqual(64); + expect(tx.operationId.endsWith(':10')).toBe(true); + }); +}); diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts index 009ae64d..741ef701 100644 --- a/apps/server/src/wallets/wallets.service.ts +++ b/apps/server/src/wallets/wallets.service.ts @@ -8,6 +8,7 @@ import { WalletTransaction } from '../entities/wallet-transaction.entity'; import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto'; import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; import { Room } from '../entities/room.entity'; +import { escapeLike } from '../common/like-escape'; const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); @@ -30,7 +31,7 @@ export class WalletsService { if (query?.keyword) { qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', { - keyword: `%${query.keyword}%`, + keyword: `%${escapeLike(query.keyword)}%`, }); } if (query?.roomType) { @@ -99,7 +100,12 @@ export class WalletsService { } async findTransactions(studentId: number) { - return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }); + // 只返回最近 200 条流水,避免无分页全量返回拖垮接口 + return this.transactionRepo.find({ + where: { studentId }, + order: { createdAt: 'DESC' }, + take: 200, + }); } async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) { @@ -163,6 +169,11 @@ export class WalletsService { const uniqueStudentIds = Array.from(new Set(batch.studentIds)); const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = []; for (const studentId of uniqueStudentIds) { + // operation_id 为 varchar(64):operationId 最长 64,直接拼 :studentId 会溢出。 + // 截断 operationId 前缀并整体兜底截到 64,保证不超长且尽量保留 :studentId。 + const opKey = operationId + ? `${operationId.slice(0, 50)}:${studentId}`.slice(0, 64) + : undefined; results.push( await this.changeBalanceOnce( { @@ -172,7 +183,7 @@ export class WalletsService { description: batch.description, }, recordedBy, - operationId ? `${operationId}:${studentId}` : undefined, + opKey, manager, ), ); @@ -206,11 +217,24 @@ export class WalletsService { return manager.save(bill); } + // 原子扣款:由数据库在同一 UPDATE 中扣减并校验余额,避免并发下 check-then-act 导致 double-spend + const debit = await manager + .createQueryBuilder() + .update(StudentWallet) + .set({ balance: () => 'balance - :amount' }) + .setParameter('amount', amount) + .where('id = :id', { id: wallet.id }) + .andWhere('balance >= :amount', { amount }) + .execute(); + if (debit.affected !== 1) { + throw new BadRequestException('余额不足'); + } + // balanceAfter 为事务内估算值(内存旧值 ± amount):原子 UPDATE 已由数据库完成, + // 并发下流水余额可能与最终 DB 余额略有偏差,但金额本身由数据库条件更新保证一致。 wallet.balance = money(Number(wallet.balance) - amount); bill.paidAmount = money(paid + amount); bill.outstandingAmount = money(Math.max(0, total - Number(bill.paidAmount))); bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid'; - await manager.save(wallet); await manager.save(bill); await manager.save( manager.create(WalletTransaction, { @@ -227,13 +251,38 @@ export class WalletsService { } async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) { - if (bill.status === 'cancelled') return bill; + // 幂等:只有成功把账单从未取消 → 取消的那一次调用才执行退款。 + // 条件 UPDATE 与调用方事务内的悲观锁是双保险,并发重复退款不会重复冲正。 + const claimed = await manager + .createQueryBuilder() + .update(Bill) + .set({ status: 'cancelled', cancelledAt: () => 'NOW()', cancelReason: reason }) + .where('id = :id', { id: bill.id }) + .andWhere('status <> :cancelled', { cancelled: 'cancelled' }) + .execute(); + if (claimed.affected !== 1) return bill; // 已被其他请求取消,直接返回(幂等) + + bill.status = 'cancelled'; + bill.cancelledAt = new Date(); + bill.cancelReason = reason; const paid = Math.max(0, Math.min(money(bill.paidAmount), money(bill.totalAmount))); if (paid > 0) { const wallet = await this.getOrCreateWallet(manager, bill.studentId); + // 原子退款:余额累加由数据库完成,避免并发覆盖 + const credit = await manager + .createQueryBuilder() + .update(StudentWallet) + .set({ balance: () => 'balance + :amount' }) + .setParameter('amount', paid) + .where('id = :id', { id: wallet.id }) + .execute(); + if (credit.affected !== 1) { + throw new BadRequestException('学生钱包不存在,退款失败'); + } + // balanceAfter 为事务内估算值(内存旧值 + paid):原子 UPDATE 已由数据库完成, + // 并发下流水余额可能与最终 DB 余额略有偏差,但冲正金额本身由数据库累加保证一致。 wallet.balance = money(Number(wallet.balance) + paid); - await manager.save(wallet); await manager.save( manager.create(WalletTransaction, { studentId: bill.studentId, @@ -246,11 +295,8 @@ export class WalletsService { }), ); } - bill.status = 'cancelled'; bill.paidAmount = 0; bill.outstandingAmount = 0; - bill.cancelledAt = new Date(); - bill.cancelReason = reason; return manager.save(bill); } @@ -269,9 +315,14 @@ export class WalletsService { .getMany(); const settled: Bill[] = []; for (const bill of bills) { - const wallet = await manager.findOne(StudentWallet, { where: { studentId } }); - if (!wallet || money(wallet.balance) <= 0) break; - settled.push(await this.debitBill(manager, bill, recordedBy)); + const paidBefore = money(bill.paidAmount); + const result = await this.debitBill(manager, bill, recordedBy); + if (money(result.paidAmount) > paidBefore) { + settled.push(result); + } else { + // 余额已不足以支付后续账单,停止结算 + break; + } } return settled; } From a32c0a073193ef045f4915533f1432731469bd99 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 9 Aug 2026 21:29:54 +0800 Subject: [PATCH 41/43] =?UTF-8?q?fix(admin):=20=E5=89=8D=E7=AB=AF=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=B8=8E=E5=AE=89=E5=85=A8=E4=BF=AE=E5=A4=8D=EF=BC=88?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E5=8B=BE=E9=80=89/=E6=9C=88=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E6=96=B0=E5=BB=BA/=E9=99=84=E4=BB=B6=E9=A2=84?= =?UTF-8?q?=E8=A7=88/=E8=80=83=E5=8B=A4=20late=20=E7=AD=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - Roles 多组勾选、Schedules 月视图新建、useDirtyGuard 稳定引用 - JinshujuMatchModal success:false、Attendance store 订阅、EditableCell Enter、DynamicChart 空数据、渲染期 ref - AttachmentsTab 附件预览不再 window.open(防存储型 XSS)+ 请求乱序防护 - 考勤 late 计入出勤率与主状态 Reviewed-by: OCR (open-codereview.ai) --- .../src/components/AiChat/DynamicChart.tsx | 9 +- .../components/AiChat/useSubmissionState.ts | 14 +-- .../src/components/EditableCell/index.tsx | 10 +++ .../src/components/JinshujuMatchModal.tsx | 4 + .../StudentProfileContent/AttachmentsTab.tsx | 90 +++++++++++++++---- .../StudentProfileContent/shared.ts | 1 + apps/admin/src/hooks/useDirtyGuard.ts | 5 +- .../Attendance/AttendanceAdmin.helpers.tsx | 7 +- apps/admin/src/pages/Attendance/index.tsx | 11 +-- apps/admin/src/pages/Roles/index.tsx | 29 +++--- apps/admin/src/pages/Schedules/index.tsx | 3 +- 11 files changed, 134 insertions(+), 49 deletions(-) diff --git a/apps/admin/src/components/AiChat/DynamicChart.tsx b/apps/admin/src/components/AiChat/DynamicChart.tsx index 06e0d0de..ee3e1f3c 100644 --- a/apps/admin/src/components/AiChat/DynamicChart.tsx +++ b/apps/admin/src/components/AiChat/DynamicChart.tsx @@ -201,11 +201,16 @@ interface ChartPreviewProps { * renders an ECharts option built from it. */ const ChartPreview: React.FC = ({ chart }) => { - const option = useMemo(() => (chart ? buildOption(chart) : {}), [chart]); + // 空/无数据时先短路,避免 buildOption 在空数据集上执行 + const hasData = !!chart && !!chart.rows && chart.rows.length > 0; + const option = useMemo( + () => (chart && hasData ? buildOption(chart) : {}), + [chart, hasData], + ); const [instance, setInstance] = useState(null); if (!chart) return null; // 空数据集:渲染明确占位,而不是一张空白图 - if (!chart.rows || chart.rows.length === 0) { + if (!hasData) { return (
diff --git a/apps/admin/src/components/AiChat/useSubmissionState.ts b/apps/admin/src/components/AiChat/useSubmissionState.ts index 60fe47e6..c3333b9c 100644 --- a/apps/admin/src/components/AiChat/useSubmissionState.ts +++ b/apps/admin/src/components/AiChat/useSubmissionState.ts @@ -1,4 +1,5 @@ import { useCallback, useRef, useState } from 'react'; +import { useLayoutEffect } from 'react'; import type { XAgentCommand_v0_9 } from '@ant-design/x-card'; /** @@ -75,11 +76,14 @@ export function useXCardSurface(surfaceId: string) { ); const surfaceKey = surfaceId; - if (idRef.current !== surfaceKey) { - // 组件复用到新 surface 时,清空历史命令重新初始化 - commandsRef.current = []; - idRef.current = surfaceKey; - } + // 渲染期保持纯函数:ref 变更放到 layout effect 里 + useLayoutEffect(() => { + if (idRef.current !== surfaceKey) { + // 组件复用到新 surface 时,清空历史命令重新初始化 + commandsRef.current = []; + idRef.current = surfaceKey; + } + }, [surfaceKey]); return { commands, pushCommands }; } diff --git a/apps/admin/src/components/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx index 32bfe8c3..e5d1a81a 100644 --- a/apps/admin/src/components/EditableCell/index.tsx +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -263,6 +263,16 @@ const EditableCell = ({ return; } if (event.key === 'Enter' && editor !== 'textarea') { + // 这些编辑器会自己消费 Enter(确认/提交选中值),不重复触发单元格保存 + if ( + editor === 'select' || + editor === 'multi-select' || + editor === 'tags' || + editor === 'date' || + editor === 'date-range' + ) { + return; + } event.preventDefault(); await save(); return; diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index 11d228e8..78cf4861 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -146,6 +146,10 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`); onApplied(); reset(); + } else { + // 接口返回 success:false 时也要结束「处理中」并给出错误提示 + message.error(res.log?.message || '处理失败,请检查后重试'); + setStep('match'); } } catch (e: unknown) { const err = e as { message?: string }; diff --git a/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx b/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx index b9fbf0e8..b9ae266f 100644 --- a/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx +++ b/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { App, Button, Popconfirm, Space, Table, Upload } from 'antd'; +import React, { useRef, useState } from 'react'; +import { App, Button, Modal, Popconfirm, Space, Table, Upload } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons'; import api from '../../api'; @@ -10,6 +10,20 @@ import { getErrorMessage } from '../../utils/error'; import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared'; import type { AttachmentRecord, TabProps } from './shared'; +type AttachmentPreview = { + url: string; + name: string; + kind: 'image' | 'pdf'; +}; + +/** 根据文件扩展名决定安全展示方式:图片/PDF 内联预览,其余一律下载 */ +function getAttachmentKind(fileName: string, mimeType?: string): 'image' | 'pdf' | 'download' { + const ext = fileName.split('.').pop()?.toLowerCase() ?? ''; + if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'].includes(ext)) return 'image'; + if (ext === 'pdf' || mimeType === 'application/pdf') return 'pdf'; + return 'download'; +} + export const AttachmentsTab: React.FC = ({ data, studentId, @@ -18,6 +32,9 @@ export const AttachmentsTab: React.FC = const { hasPermission } = usePermission(); const canPurgeArchive = hasPermission('archive:purge'); const [uploading, setUploading] = useState(false); + const [preview, setPreview] = useState(null); + // 预览请求序号:快速点不同行「查看」时,慢的旧响应回来直接丢弃,避免覆盖新预览 + const previewSeqRef = useRef(0); const deleteAttachmentMutation = useApiMutation( async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`), @@ -33,6 +50,39 @@ export const AttachmentsTab: React.FC = { invalidate: [['archive', studentId]] }, ); + const closePreview = () => { + previewSeqRef.current += 1; // 关闭后仍在途的旧响应也不再落地 + if (preview?.url) URL.revokeObjectURL(preview.url); + setPreview(null); + }; + + const openAttachment = async (record: AttachmentRecord) => { + const seq = ++previewSeqRef.current; + try { + const blob = await api.get(`/archive/${studentId}/attachments/${record.id}`, { + responseType: 'blob', + }); + if (seq !== previewSeqRef.current) return; // 已有更新的查看请求,丢弃本次慢响应 + const kind = getAttachmentKind(record.fileName, record.mimeType); + const url = URL.createObjectURL(blob); + if (kind === 'download') { + // 非内联类型通过 download 属性触发下载,避免以页面同源打开可执行内容 + const a = document.createElement('a'); + a.href = url; + a.download = record.fileName || 'attachment'; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + } else { + if (preview?.url) URL.revokeObjectURL(preview.url); + setPreview({ url, name: record.fileName || 'attachment', kind }); + } + } catch (e: unknown) { + message.error(getErrorMessage(e, '查看失败')); + } + }; + const handleDelete = async (attachmentId: number) => { try { await deleteAttachmentMutation.mutateAsync(attachmentId); @@ -72,22 +122,7 @@ export const AttachmentsTab: React.FC = title: '操作', render: (_: unknown, record: AttachmentRecord) => ( - {hasPermission('student:edit') && record.status !== 'archived' ? ( @@ -149,6 +184,25 @@ export const AttachmentsTab: React.FC = }} style={{ marginTop: 16 }} /> + + {preview?.kind === 'image' ? ( + {preview.name} + ) : preview?.kind === 'pdf' ? ( +