diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx index 7ed21b6..8a15923 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 c179270..86c3d99 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 f176047..1c221a7 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 0000000..71fb386 --- /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 fc9c78e..58ea6d7 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 8148f58..58d4eeb 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 0f4a8de..aec468a 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 ab1ac75..14ae4c3 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 4d7bdea..043032c 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 0000000..60fe47e --- /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 0000000..1fbee62 --- /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 0000000..375056b --- /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 c06fbe3..11d228e 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 0000000..d9b4a57 --- /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 0000000..9056d1e --- /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 0000000..3227656 --- /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 a07a8b7..4c1de96 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 549e421..ac49ff5 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 0000000..fa32c6b --- /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 0000000..3b6df68 --- /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 4a210b7..6b33e96 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 9a36429..ba98017 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 f1fce2b..4075ac7 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 6768de6..1306fcd 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 9a78039..2db7528 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 20358bd..f05b9ce 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 e8d9dff..ffef1fb 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 fd0c375..1c29f7e 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 f01a114..9582a7c 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 f06c0b7..f69f6de 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 8efb558..1aefcd8 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 5a8098c..7ed5832 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 c188c4c..9ce4a07 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 201e302..c21053b 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 c601801..6bcb9d6 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 163ea8e..0995269 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 6541de8..706f396 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 8364f0f..2e0469f 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 cce82ba..ef3b335 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`) +仍可渲染——建议先迁移历史数据或保留兼容读取。