feat(admin): 用户体验体系化提升与高危缺陷修复

UX 缺陷修复:
- 校验失败不再卡死弹窗按钮(Users/Roles/Bills)
- 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选
- AI 表单/批量确认不再出现"假成功"
- Dashboard 各数据模块独立加载,单接口失败不再整页清零
- 房间可视化加载失败显示错误态而非永久转圈
- 学生编辑表单回填前重置,避免字段残留污染
- 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单
- 金数据匹配关闭前确认,同步中禁止误关

体验提升:
- 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试
- 全局 ErrorBoundary + RouteKeeper 逐页兜底
- 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据
- 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡
- 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期,
  ArtifactErrorBoundary 渲染降级,图表空数据占位
- AI 助手欢迎语与建议话术按角色定制,会话列表空态引导
- 更新 a2ui-contract.md 契约文档说明实现现状
This commit is contained in:
2026-08-07 17:23:23 +08:00
parent db0d972633
commit 67435e46ca
54 changed files with 2298 additions and 1177 deletions

View File

@@ -64,6 +64,14 @@ export const AiChatSidebar: React.FC<AiChatSidebarProps> = ({
} }
/> />
{loadingList && <Spin className="ai-chat-sidebar__loading" />} {loadingList && <Spin className="ai-chat-sidebar__loading" />}
{!loadingList && !selectionMode && conversationCount === 0 ? (
<div className="ai-chat-sidebar__empty">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
</div>
) : null}
<div className="ai-chat-sidebar__footer"> <div className="ai-chat-sidebar__footer">
{selectionMode ? ( {selectionMode ? (
<> <>

View File

@@ -20,8 +20,11 @@ import {
} from 'antd'; } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { useUserStore } from '../../store/user/userStore';
import { usePermissionStore } from '../../store/permission/permissionStore';
import { aiChatApi, conversationStreamUrl } from './api'; import { aiChatApi, conversationStreamUrl } from './api';
import { GongxueAiChatProvider } from './provider'; import { GongxueAiChatProvider } from './provider';
import { welcomeDescription, workflowPromptExamples } from './welcomeCopy';
import { ImportWizardModal } from '../ImportWizard/ImportWizardModal'; import { ImportWizardModal } from '../ImportWizard/ImportWizardModal';
import type { AiSkill } from './types'; import type { AiSkill } from './types';
import { useAiChatMessageActions } from './useAiChatMessageActions'; import { useAiChatMessageActions } from './useAiChatMessageActions';
@@ -51,6 +54,8 @@ interface AiChatDrawerProps {
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequestingChange }) => { const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequestingChange }) => {
const { modal } = App.useApp(); const { modal } = App.useApp();
const user = useUserStore((state) => state.user);
const permissions = usePermissionStore((state) => state.permissions);
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.sm; const isMobile = !screens.sm;
const [loadingList, setLoadingList] = useState(false); const [loadingList, setLoadingList] = useState(false);
@@ -524,12 +529,21 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
title="你好,我是恭学 AI 助手" title="你好,我是恭学 AI 助手"
description={ description={
lockedSkill?.description || lockedSkill?.description ||
'我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。' welcomeDescription(user?.roles ?? [], permissions)
} }
/> />
<Prompts <Prompts
title="你可以这样问" title="你可以这样问"
items={promptItems} items={[
...promptItems,
...workflowPromptExamples(user?.roles ?? [], permissions).map(
(item, index) => ({
key: `workflow-${index}`,
label: item.label,
description: item.description,
}),
),
]}
wrap wrap
onItemClick={({ data }) => submit(String(data.label || ''))} onItemClick={({ data }) => submit(String(data.label || ''))}
/> />

View File

@@ -16,6 +16,7 @@ import { useUserStore } from '../../store/user/userStore';
import { DynamicChart } from './DynamicChart'; import { DynamicChart } from './DynamicChart';
import { DynamicForm } from './DynamicForm'; import { DynamicForm } from './DynamicForm';
import { DynamicReview } from './DynamicReview'; import { DynamicReview } from './DynamicReview';
import { ArtifactErrorBoundary } from './ArtifactErrorBoundary';
import { LiteCodeHighlighter } from './LiteCodeHighlighter'; import { LiteCodeHighlighter } from './LiteCodeHighlighter';
import { LiteMermaid } from './LiteMermaid'; import { LiteMermaid } from './LiteMermaid';
import type { import type {
@@ -355,26 +356,30 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
/> />
)} )}
{(message.forms ?? []).map((form) => ( {(message.forms ?? []).map((form) => (
<DynamicForm <ArtifactErrorBoundary key={form.id} title="表单">
key={form.id} <DynamicForm
form={form} form={form}
disabled={streaming} disabled={streaming}
onSubmit={(values) => onSubmitForm?.(form, values)} onSubmit={(values) => onSubmitForm?.(form, values)}
/> />
</ArtifactErrorBoundary>
))} ))}
{(message.reviews ?? []).map((review: AiReviewSchema) => ( {(message.reviews ?? []).map((review: AiReviewSchema) => (
<DynamicReview <ArtifactErrorBoundary key={review.id} title="导入预览">
key={review.id} <DynamicReview
review={review} review={review}
messageId={typeof message.id === 'number' ? message.id : undefined} messageId={typeof message.id === 'number' ? message.id : undefined}
disabled={streaming} disabled={streaming}
onSubmit={(reviewId) => onSubmitReview?.(reviewId, review.title)} onSubmit={(reviewId) => onSubmitReview?.(reviewId, review.title)}
onConfirmStep={onConfirmReviewStep} onConfirmStep={onConfirmReviewStep}
onConfirmGroup={onConfirmReviewGroup} onConfirmGroup={onConfirmReviewGroup}
/> />
</ArtifactErrorBoundary>
))} ))}
{(message.charts ?? []).map((chart: AiChartSchema) => ( {(message.charts ?? []).map((chart: AiChartSchema) => (
<DynamicChart key={chart.id} chart={chart} /> <ArtifactErrorBoundary key={chart.id} title="图表">
<DynamicChart chart={chart} />
</ArtifactErrorBoundary>
))} ))}
{message.error && <Alert type="error" showIcon title={message.error} />} {message.error && <Alert type="error" showIcon title={message.error} />}
{message.cancelled && <Typography.Text type="secondary"></Typography.Text>} {message.cancelled && <Typography.Text type="secondary"></Typography.Text>}

View File

@@ -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 (
<Alert
type="warning"
showIcon
title={this.props.title ? `${this.props.title}渲染失败` : '此内容渲染失败'}
description="请让 AI 重新生成,或刷新后重试。"
/>
);
}
return this.props.children;
}
}
export default ArtifactErrorBoundary;

View File

@@ -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 { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card';
import { Button, Spin, Tag, Tooltip, Typography } from 'antd'; import { Button, Spin, Tag, Tooltip, Typography } from 'antd';
import { DownloadOutlined } from '@ant-design/icons'; import { DownloadOutlined } from '@ant-design/icons';
import type { EChartsType } from 'echarts/core'; import type { EChartsType } from 'echarts/core';
import type { EChartsOption } from '../../components/ECharts'; import type { EChartsOption } from '../../components/ECharts';
import type { AiChartSchema } from './types'; import type { AiChartSchema } from './types';
import { useXCardSurface } from './useSubmissionState';
// echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取 // echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取
const ReactECharts = lazy(() => import('../../components/ECharts')); const ReactECharts = lazy(() => import('../../components/ECharts'));
@@ -202,6 +203,27 @@ const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]); const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]);
const [instance, setInstance] = useState<EChartsType | null>(null); const [instance, setInstance] = useState<EChartsType | null>(null);
if (!chart) return null; if (!chart) return null;
// 空数据集:渲染明确占位,而不是一张空白图
if (!chart.rows || chart.rows.length === 0) {
return (
<div className="ai-chat-chart-card">
<div className="ai-chat-chart-card__header">
<Typography.Text strong>{chart.title}</Typography.Text>
<Tag color="blue">{CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}</Tag>
</div>
<div
style={{
height: 120,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography.Text type="secondary"></Typography.Text>
</div>
</div>
);
}
const downloadImage = () => { const downloadImage = () => {
if (!instance) return; if (!instance) return;
@@ -254,46 +276,39 @@ export interface DynamicChartProps {
* so history replays identically. * so history replays identically.
*/ */
export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => { export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => {
const commandsRef = useRef<XAgentCommand_v0_9[]>([]); const sid = surfaceId(chart.id);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]); const { commands, pushCommands } = useXCardSurface(sid);
const idRef = useRef<string>('');
useEffect(() => { useEffect(() => {
const sid = surfaceId(chart.id); const cmds: XAgentCommand_v0_9[] = [
if (idRef.current !== sid) { {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
version: 'v0.9', version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID }, createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID },
});
}
cmds.push({
version: 'v0.9',
updateDataModel: {
surfaceId: sid,
path: '/chart',
value: chart,
}, },
}); {
cmds.push({ version: 'v0.9',
version: 'v0.9', updateDataModel: {
updateComponents: { surfaceId: sid,
surfaceId: sid, path: '/chart',
components: [ value: chart,
{ },
id: 'root',
component: 'ChartPreview',
chart: { path: '/chart' },
},
],
}, },
}); {
setCommands([...cmds]); version: 'v0.9',
}, [chart]); updateComponents: {
surfaceId: sid,
components: [
{
id: 'root',
component: 'ChartPreview',
chart: { path: '/chart' },
},
],
},
},
];
pushCommands(cmds);
}, [chart, pushCommands, sid]);
return ( return (
<div className="ai-chat-chart"> <div className="ai-chat-chart">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo } from 'react';
import { import {
XCard, XCard,
registerCatalog, registerCatalog,
@@ -18,6 +18,7 @@ import {
} from 'antd'; } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { AiFormField, AiFormSchema } from './types'; import type { AiFormField, AiFormSchema } from './types';
import { useSubmissionState, useXCardSurface } from './useSubmissionState';
const FORM_CATALOG_ID = 'gongxue-form-catalog'; const FORM_CATALOG_ID = 'gongxue-form-catalog';
@@ -179,63 +180,46 @@ export interface DynamicFormProps {
* success/failure/loading transitions are pushed as incremental commands. * success/failure/loading transitions are pushed as incremental commands.
*/ */
export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => { export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => {
const [submitting, setSubmitting] = useState(false); const sid = surfaceId(form.id);
const [submitted, setSubmitted] = useState(false); const { submitting, submitted, error, run } = useSubmissionState();
const [error, setError] = useState<string | null>(null); const { commands, pushCommands } = useXCardSurface(sid);
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
const idRef = useRef<string>('');
useEffect(() => { useEffect(() => {
const sid = surfaceId(form.id); const cmds: XAgentCommand_v0_9[] = [
if (idRef.current !== sid) { {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
version: 'v0.9', version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID }, 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',
version: 'v0.9', updateDataModel: {
updateComponents: { surfaceId: sid,
surfaceId: sid, path: '/form',
components: [ value: { ...form, submitting, submitted, error },
{ },
id: 'root',
component: 'FormPreview',
form: { path: '/form' },
disabled: Boolean(disabled),
},
],
}, },
}); {
setCommands([...cmds]); version: 'v0.9',
}, [disabled, error, form, submitted, submitting]); 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<string, unknown>) => { const handleSubmit = (values: Record<string, unknown>) => {
if (submitting) return; void run(async () => {
setSubmitting(true);
setError(null);
try {
await onSubmit(values); await onSubmit(values);
setSubmitted(true); });
} catch (reason) {
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
}; };
const handleAction = (payload: ActionPayload) => { const handleAction = (payload: ActionPayload) => {

View File

@@ -17,6 +17,7 @@ import {
type TableProps, type TableProps,
} from 'antd'; } from 'antd';
import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types'; import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types';
import { useXCardSurface } from './useSubmissionState';
import { import {
GROUP_STATUS_LABELS, GROUP_STATUS_LABELS,
SECTION_ORDER, SECTION_ORDER,
@@ -430,9 +431,8 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
activeTypeRef.current = activeType; activeTypeRef.current = activeType;
const [localReview, setLocalReview] = useState<AiReviewSchema>(review); const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const commandsRef = useRef<XAgentCommand_v0_9[]>([]); const sid = surfaceId(localReview.id);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]); const { commands, pushCommands } = useXCardSurface(sid);
const idRef = useRef<string>('');
useEffect(() => { useEffect(() => {
setLocalReview(review); setLocalReview(review);
@@ -455,55 +455,51 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
}, [review]); }, [review]);
useEffect(() => { useEffect(() => {
const sid = surfaceId(localReview.id); const cmds: XAgentCommand_v0_9[] = [
if (idRef.current !== sid) { {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
version: 'v0.9', version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: REVIEW_CATALOG_ID }, createSurface: { surfaceId: sid, catalogId: REVIEW_CATALOG_ID },
}); },
} {
cmds.push({ version: 'v0.9',
version: 'v0.9', updateDataModel: {
updateDataModel: { surfaceId: sid,
surfaceId: sid, path: '/review',
path: '/review', value: {
value: { ...localReview,
...localReview, submitting,
submitting, activeKey,
activeKey, activeType,
activeType, submittingKey,
submittingKey, submittingGroup,
submittingGroup, error,
error, },
}, },
}, },
}); {
cmds.push({ version: 'v0.9',
version: 'v0.9', updateComponents: {
updateComponents: { surfaceId: sid,
surfaceId: sid, components: [
components: [ {
{ id: 'root',
id: 'root', component: 'ReviewPreview',
component: 'ReviewPreview', review: { path: '/review' },
review: { path: '/review' }, disabled: Boolean(disabled),
disabled: Boolean(disabled), },
}, ],
], },
}, },
}); ];
setCommands([...cmds]); pushCommands(cmds);
}, [ }, [
activeKey, activeKey,
activeType, activeType,
disabled, disabled,
error, error,
localReview, localReview,
pushCommands,
sid,
submitting, submitting,
submittingGroup, submittingGroup,
submittingKey, submittingKey,

View File

@@ -138,6 +138,16 @@
inset: 68px 0 auto; 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 { .ai-chat-sidebar__footer {
flex: none; flex: none;
display: flex; display: flex;

View File

@@ -327,8 +327,9 @@ export function useAiChatMessageActions({
); );
const submitForm = useCallback( const submitForm = useCallback(
(form: AiFormSchema, values: Record<string, unknown>) => { async (form: AiFormSchema, values: Record<string, unknown>): Promise<void> => {
if (!activeId || isRequesting) return; if (!activeId) throw new Error('当前会话不可用,请稍后重试');
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再提交表单');
requestWithStatus({ requestWithStatus({
message: '表单提交', message: '表单提交',
attachmentIds: [], attachmentIds: [],
@@ -342,8 +343,9 @@ export function useAiChatMessageActions({
); );
const submitReview = useCallback( const submitReview = useCallback(
(reviewId: string, reviewTitle?: string) => { async (reviewId: string, reviewTitle?: string): Promise<void> => {
if (!activeId || isRequesting) return; if (!activeId) throw new Error('当前会话不可用,请稍后重试');
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再确认导入');
requestWithStatus({ requestWithStatus({
message: '确认批量导入', message: '确认批量导入',
attachmentIds: [], attachmentIds: [],

View File

@@ -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<string | null>(null);
const submittingRef = useRef(false);
const run = useCallback(async (task: () => Promise<void> | 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<XAgentCommand_v0_9[]>([]);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
const idRef = useRef<string>('');
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 };
}

View File

@@ -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;
}

View File

@@ -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 (
<Result
status="error"
title="页面出现异常"
subTitle="请刷新页面重试;若问题持续,请联系管理员。"
extra={
<Button type="primary" onClick={() => window.location.reload()}>
</Button>
}
/>
);
}
return this.props.children;
}
}
export default AppErrorBoundary;

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useImmer } from 'use-immer'; 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 { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
import api from '../api'; import api from '../api';
import { message } from '../ui/app-message'; import { message } from '../ui/app-message';
@@ -30,6 +30,7 @@ interface MatchModalProps {
} }
const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => { const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => {
const { modal } = App.useApp();
const { hasPermission, hasAllPermissions, permissionsReady } = usePermission(); const { hasPermission, hasAllPermissions, permissionsReady } = usePermission();
const canTriggerSync = hasPermission('sync:trigger'); const canTriggerSync = hasPermission('sync:trigger');
const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger'); const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger');
@@ -168,6 +169,22 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
}; };
const handleClose = () => { 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(); reset();
onClose(); onClose();
}; };
@@ -330,6 +347,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
onCancel={handleClose} onCancel={handleClose}
width={step === 'match' || step === 'applying' ? 900 : 640} width={step === 'match' || step === 'applying' ? 900 : 640}
mask={{ closable: false }} mask={{ closable: false }}
closable={step !== 'applying'}
footer={ footer={
step === 'connection' step === 'connection'
? [ ? [
@@ -380,7 +398,12 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
{step === 'rule' ? renderRuleStep() : null} {step === 'rule' ? renderRuleStep() : null}
{step === 'match' ? renderMatchStep() : null} {step === 'match' ? renderMatchStep() : null}
{step === 'applying' ? ( {step === 'applying' ? (
<Spin description="正在同步..." style={{ display: 'block', margin: '48px auto' }} /> <>
<Spin description="正在同步,请勿关闭窗口..." style={{ display: 'block', margin: '48px auto' }} />
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>
</Typography.Text>
</>
) : null} ) : null}
</Modal> </Modal>
); );

View File

@@ -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<NextStepHintProps> = ({
title,
description,
action,
closable = true,
onClose,
}) => {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<Card
size="small"
className="next-step-hint"
style={{ marginBottom: 16, borderColor: '#b7d4ff', background: '#f0f7ff' }}
styles={{ body: { padding: '10px 16px' } }}
>
<Flex align="center" justify="space-between" gap={8} wrap>
<Flex align="center" gap={8} wrap>
<StepForwardOutlined style={{ color: '#1677ff' }} />
<Typography.Text strong>{title}</Typography.Text>
{description ? <Typography.Text type="secondary">{description}</Typography.Text> : null}
</Flex>
<Flex gap={4} align="center">
{action ? (
<Button type="primary" size="small" onClick={action.onClick}>
{action.label} <RightOutlined />
</Button>
) : null}
{closable ? (
<Button
type="text"
size="small"
icon={<CloseOutlined />}
aria-label="关闭提示"
onClick={() => {
setDismissed(true);
onClose?.();
}}
/>
) : null}
</Flex>
</Flex>
</Card>
);
};
export default NextStepHint;

View File

@@ -0,0 +1,156 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Tour, type TourProps } from 'antd';
import { getRoleDomains } from '../../auth/menu-policy';
const STORAGE_KEY = 'onboarding_seen_v1';
export interface RoleTourProps {
roles: readonly string[];
permissions: readonly string[];
enabled?: boolean;
}
interface TourStep {
target: () => HTMLElement;
title: string;
description: string;
}
/** 按 antd Menu 渲染的菜单项查找目标元素(菜单项 title 属性即菜单文案) */
function menuTarget(label: string): () => HTMLElement {
return () => document.querySelector<HTMLElement>(`[title="${label}"]`) as HTMLElement;
}
/** 右上角 AI 助手按钮 */
const aiTarget = (): HTMLElement =>
document.querySelector<HTMLElement>('button[aria-label="打开 AI 助理"]') as HTMLElement;
function buildSteps(domains: Set<string>): TourStep[] {
const steps: TourStep[] = [];
const has = (key: string) => domains.has(key);
if (has('teacher')) {
steps.push(
{
target: menuTarget('今日教学'),
title: '今日教学',
description: '在这里查看今天的课程安排。课程开始后可以拉取钉钉考勤并点名。',
},
{
target: menuTarget('课程考勤'),
title: '课程考勤',
description: '查看历史考勤记录,课程截止后系统会自动结算缺勤。',
},
);
}
if (has('academic')) {
steps.push(
{
target: menuTarget('学生管理'),
title: '学生管理',
description: '管理学生档案可单个录入、Excel 批量导入,或用 AI 助手帮你录入。',
},
{
target: menuTarget('班级管理'),
title: '班级管理',
description: '学生入学后先分班,再排课、考勤,形成完整教学闭环。',
},
);
}
if (has('accommodation')) {
steps.push(
{
target: menuTarget('住宿总览'),
title: '住宿总览',
description: '可视化查看各宿舍入住情况,支持历史日期回溯和查寝。',
},
{
target: menuTarget('入住管理'),
title: '入住管理',
description: '学生入住/退宿/换宿都在这里办理。入住后再录费用、生成账单。',
},
{
target: menuTarget('账单管理'),
title: '账单管理',
description: '每月生成账单、确认并标记已付,完成「住宿→计费」闭环。',
},
);
}
if (has('classroom')) {
steps.push(
{
target: menuTarget('教室排期'),
title: '教室排期',
description: '查看教室占用与排期,避免时间冲突。',
},
{
target: menuTarget('租赁订单'),
title: '租赁订单',
description: '管理教室租赁订单与合同,跟踪租期与状态。',
},
);
}
if (has('system') || has('super')) {
steps.push(
{
target: menuTarget('账号管理'),
title: '账号管理',
description: '管理系统账号、角色与权限,为不同岗位分配对应能力。',
},
);
}
if (has('super')) {
steps.unshift({
target: menuTarget('数据面板'),
title: '数据面板',
description: '全局经营概览:入住率、收入、待办都在这里。',
});
}
steps.push({
target: aiTarget,
title: 'AI 助手',
description:
'有任何问题都可以问我。我可以帮你查询数据、录入学生、生成批量导入预览——写操作都会先经你确认。',
});
return steps;
}
/**
* 首次登录角色引导:按用户角色展示核心业务闭环的 Tour。
* 只对桌面端展示一次localStorage 标记),可随时关闭。
*/
export const RoleTour: React.FC<RoleTourProps> = ({ roles, permissions, enabled = true }) => {
const [open, setOpen] = useState(false);
const domains = useMemo(() => getRoleDomains(roles, permissions), [roles, permissions]);
const steps = useMemo(() => buildSteps(domains), [domains]);
useEffect(() => {
if (!enabled) return;
if (window.localStorage.getItem(STORAGE_KEY)) return;
if (window.innerWidth < 992) return; // 仅桌面端
// 等菜单渲染完成后再弹引导
const timer = window.setTimeout(() => setOpen(true), 600);
return () => window.clearTimeout(timer);
}, [enabled]);
const handleFinish = () => {
window.localStorage.setItem(STORAGE_KEY, '1');
setOpen(false);
};
const config: TourProps = {
open,
onClose: handleFinish,
// target 函数运行时返回 HTMLElement | null与 antd 类型(不接受联合返回)做一次断言
steps: steps as TourProps['steps'],
placement: 'right',
mask: true,
};
return <Tour {...config} />;
};
export default RoleTour;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Button, Empty } from 'antd';
export interface QueryEmptyAction {
label: string;
onClick: () => void;
type?: 'primary' | 'default';
icon?: React.ReactNode;
}
export interface QueryEmptyProps {
/** 空状态描述,默认「暂无数据」 */
description?: string;
/** 主操作按钮(如「添加学生」「导入 Excel」 */
action?: QueryEmptyAction;
/** 自定义空态插图 */
image?: React.ReactNode;
}
/**
* 统一空状态:数据确实为空时渲染本组件,并附带主操作按钮引导用户开始。
*/
export const QueryEmpty: React.FC<QueryEmptyProps> = ({ description = '暂无数据', action, image }) => {
return (
<Empty image={image} description={description}>
{action ? (
<Button type={action.type ?? 'primary'} icon={action.icon} onClick={action.onClick}>
{action.label}
</Button>
) : null}
</Empty>
);
};
export default QueryEmpty;

View File

@@ -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<QueryErrorStateProps> = ({
title = '数据加载失败',
description = '请检查网络后重试。',
onRetry,
compact = false,
}) => {
if (compact) {
return (
<div style={{ padding: '32px 16px', textAlign: 'center' }}>
<Typography.Text type="secondary">{title}</Typography.Text>
{description ? (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{description}
</Typography.Text>
</div>
) : null}
{onRetry ? (
<div style={{ marginTop: 12 }}>
<Button size="small" onClick={onRetry}>
</Button>
</div>
) : null}
</div>
);
}
return (
<Result
status="warning"
title={title}
subTitle={description}
extra={onRetry ? <Button type="primary" onClick={onRetry}></Button> : undefined}
/>
);
};
export default QueryErrorState;

View File

@@ -0,0 +1,4 @@
export { QueryErrorState } from './QueryErrorState';
export type { QueryErrorStateProps } from './QueryErrorState';
export { QueryEmpty } from './QueryEmpty';
export type { QueryEmptyProps, QueryEmptyAction } from './QueryEmpty';

View File

@@ -1,11 +1,16 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { useLocation, useOutlet } from 'react-router'; import { useLocation, useOutlet } from 'react-router';
import AppErrorBoundary from './AppErrorBoundary';
import { ActivePageContext } from './routeKeeperContext';
const MAX_CACHED_PAGES = 30; const MAX_CACHED_PAGES = 30;
/** /**
* 路由保活:切换页面时保留已访问页面的组件实例(输入、滚动、弹窗状态不丢失)。 * 路由保活:切换页面时保留已访问页面的组件实例(输入、滚动、弹窗状态不丢失)。
* 隐藏页面仍挂载在 DOM 中,仅通过 display:none 隐藏。 * 隐藏页面仍挂载在 DOM 中,仅通过 display:none 隐藏。
*
* - 每个缓存页外层包裹 AppErrorBoundary单页渲染异常不影响其他缓存页。
* - 通过 ActivePageContext 向页面暴露「当前激活页路径」,供 usePageVisible 使用。
*/ */
export const RouteKeeper: React.FC = () => { export const RouteKeeper: React.FC = () => {
const location = useLocation(); const location = useLocation();
@@ -26,17 +31,17 @@ export const RouteKeeper: React.FC = () => {
} }
return ( return (
<> <ActivePageContext.Provider value={pageKey}>
{Array.from(cacheRef.current.entries()).map(([key, node]) => ( {Array.from(cacheRef.current.entries()).map(([key, node]) => (
<div <div
key={key} key={key}
className="route-keeper-page" className="route-keeper-page"
style={{ display: key === pageKey ? undefined : 'none' }} style={{ display: key === pageKey ? undefined : 'none' }}
> >
{node} <AppErrorBoundary>{node}</AppErrorBoundary>
</div> </div>
))} ))}
</> </ActivePageContext.Provider>
); );
}; };

View File

@@ -31,7 +31,7 @@ import { validateResponse } from '../../utils/validate';
import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas'; import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas';
import EditableCell from '../EditableCell'; import EditableCell from '../EditableCell';
import { usePermission } from '../../hooks/usePermission'; 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 { ADMISSION_STATUS_MAP, ATTENDANCE_STATUS_MAP, SESSION_LABELS, getOptionLabel } from './shared';
import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared'; import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared';
@@ -478,19 +478,15 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
data: aggregateData, data: aggregateData,
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch, refetch,
} = useQuery<StudentProfileAggregate | null>({ } = useQuery<StudentProfileAggregate | null>({
queryKey: ['archive', studentId], queryKey: ['archive', studentId],
queryFn: async () => { queryFn: async () => {
try { return validateResponse<StudentProfileAggregate>(
return validateResponse<StudentProfileAggregate>( studentProfileAggregateSchema,
studentProfileAggregateSchema, await api.get<StudentProfileAggregate>(`/archive/${studentId}`),
await api.get<StudentProfileAggregate>(`/archive/${studentId}`), );
);
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败'));
return null;
}
}, },
}); });
const { data: organizations = [] } = useQuery< const { data: organizations = [] } = useQuery<
@@ -582,6 +578,15 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
</div> </div>
); );
} }
if (isError) {
return (
<QueryErrorState
title="档案数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
);
}
return null; return null;
} }

View File

@@ -0,0 +1,10 @@
import { createContext, useContext } from 'react';
/**
* 当前激活的页面路径(由 RouteKeeper 提供)。
* RouteKeeper 用 display:none 保活已访问页面,页面组件本身不会重新挂载,
* 因此需要该上下文让每个缓存页感知「自己是否处于激活状态」。
*/
export const ActivePageContext = createContext<string>('');
export const useActivePage = (): string => useContext(ActivePageContext);

View File

@@ -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]);
}

View File

@@ -40,6 +40,7 @@ import { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/
import NotificationBell from '../components/NotificationBell'; import NotificationBell from '../components/NotificationBell';
import RouteDock from '../components/RouteDock'; import RouteDock from '../components/RouteDock';
import RouteKeeper from '../components/RouteKeeper'; import RouteKeeper from '../components/RouteKeeper';
import RoleTour from '../components/Onboarding/RoleTour';
import { buildMenu, type AppMenuItem } from '../auth/menu-policy'; import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer')); const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer'));
@@ -431,6 +432,7 @@ const MainLayout: React.FC = () => {
/> />
</React.Suspense> </React.Suspense>
)} )}
<RoleTour roles={user?.roles ?? []} permissions={permissions} />
</Layout> </Layout>
); );
}; };

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App'; import App from './App';
import AppErrorBoundary from './components/AppErrorBoundary';
import './index.css'; import './index.css';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
@@ -42,9 +43,11 @@ if (!rootElement) throw new Error('未找到 #root 挂载点');
ReactDOM.createRoot(rootElement).render( ReactDOM.createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <AppErrorBoundary>
<App /> <QueryClientProvider client={queryClient}>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />} <App />
</QueryClientProvider> {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</AppErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -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 { Alert, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { QueryErrorState } from '../../components/QueryState';
import { import {
filterLessonAttendanceRecords, filterLessonAttendanceRecords,
getPunchDisplayInfo, getPunchDisplayInfo,
@@ -84,37 +85,44 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
const [session, setSession] = useState<LessonAttendanceSession | null>(null); const [session, setSession] = useState<LessonAttendanceSession | null>(null);
const [records, setRecords] = useState<LessonAttendanceRecord[]>([]); const [records, setRecords] = useState<LessonAttendanceRecord[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [filter, setFilter] = useState<LessonAttendanceFilter>('all'); const [filter, setFilter] = useState<LessonAttendanceFilter>('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<LessonAttendanceResponse>(
`/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(() => { useEffect(() => {
if (!schedule) return; if (!schedule) return;
let cancelled = false; cancelledRef.current = false;
setLoadedSchedule(schedule); setLoadedSchedule(schedule);
setLoading(true); void loadLesson();
const date = dayjs().format('YYYY-MM-DD');
void api
.post<LessonAttendanceResponse>(`/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);
});
return () => { return () => {
cancelled = true; cancelledRef.current = true;
}; };
}, [schedule]); }, [schedule, loadLesson]);
const updateRecord = useCallback(async (record: LessonAttendanceRecord, status: string) => { const updateRecord = useCallback(async (record: LessonAttendanceRecord, status: string) => {
const previous = record.status; const previous = record.status;
@@ -185,19 +193,26 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
{filteredRecords.length} / {records.length} {filteredRecords.length} / {records.length}
</span> </span>
</div> </div>
<Table<LessonAttendanceRecord> {error ? (
rowKey="id" <QueryErrorState
loading={loading} title="本节课考勤加载失败"
dataSource={filteredRecords} description={error}
pagination={false} onRetry={() => void loadLesson()}
locale={{ />
emptyText: ( ) : (
<Empty <Table<LessonAttendanceRecord>
image={Empty.PRESENTED_IMAGE_SIMPLE} rowKey="id"
description={records.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'} loading={loading}
/> dataSource={filteredRecords}
), pagination={false}
}} locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={records.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
/>
),
}}
columns={[ columns={[
{ {
title: '学生', title: '学生',
@@ -264,7 +279,8 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
render: (value: string | null) => value || <span className="muted-text"></span>, render: (value: string | null) => value || <span className="muted-text"></span>,
}, },
]} ]}
/> />
)}
</Drawer> </Drawer>
); );
}; };

View File

@@ -11,13 +11,14 @@ import {
attendanceSummarySchema, attendanceSummarySchema,
dingTalkSyncStatusSchema, dingTalkSyncStatusSchema,
} from '../../api/schemas'; } from '../../api/schemas';
import { Form, Grid } from 'antd'; import { App, Form, Grid } from 'antd';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { useUserStore } from '../../store/user/userStore'; import { useUserStore } from '../../store/user/userStore';
import type { AttendanceSummary } from './attendance-workspace'; 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 { AttendanceAdminHeader } from './AttendanceAdminHeader';
import { buildAttendanceAdminColumns } from './AttendanceAdminColumns'; import { buildAttendanceAdminColumns } from './AttendanceAdminColumns';
import { PeriodConfigModal, StudentDetailDrawer } from './AttendanceAdminModals'; import { PeriodConfigModal, StudentDetailDrawer } from './AttendanceAdminModals';
@@ -39,6 +40,7 @@ import {
} from './AttendanceAdmin.helpers'; } from './AttendanceAdmin.helpers';
export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const { modal } = App.useApp();
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.sm; const isMobile = !screens.sm;
const [periodModalOpen, setPeriodModalOpen] = useState(false); const [periodModalOpen, setPeriodModalOpen] = useState(false);
@@ -56,6 +58,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null); const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null); const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
useVisibleRefetch(['attendance', 'records']);
const recordQueryKey = [ const recordQueryKey = [
'attendance', 'attendance',
'records', 'records',
@@ -70,42 +73,27 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
const { data: classOptions = [] } = useQuery<ClassOption[]>({ const { data: classOptions = [] } = useQuery<ClassOption[]>({
queryKey: ['attendance', 'meta', 'classes'], queryKey: ['attendance', 'meta', 'classes'],
queryFn: async () => { queryFn: async () =>
try { validateResponse<ClassOption[]>(
return validateResponse<ClassOption[]>( attendanceClassOptionsSchema,
attendanceClassOptionsSchema, await api.get<ClassOption[]>('/attendance-records/classes'),
await api.get<ClassOption[]>('/attendance-records/classes'), ),
);
} catch {
return [];
}
},
}); });
const { data: alerts = [] } = useQuery<AlertItem[]>({ const { data: alerts = [] } = useQuery<AlertItem[]>({
queryKey: ['attendance', 'meta', 'alerts'], queryKey: ['attendance', 'meta', 'alerts'],
queryFn: async () => { queryFn: async () =>
try { validateResponse<AlertItem[]>(
return validateResponse<AlertItem[]>( attendanceAlertsSchema,
attendanceAlertsSchema, await api.get<AlertItem[]>('/attendance-records/alerts'),
await api.get<AlertItem[]>('/attendance-records/alerts'), ),
);
} catch {
return [];
}
},
}); });
const { data: periods = DEFAULT_ATTENDANCE_PERIODS } = useQuery<AttendancePeriodConfigItem[]>({ const { data: periods = DEFAULT_ATTENDANCE_PERIODS } = useQuery<AttendancePeriodConfigItem[]>({
queryKey: ['attendance', 'meta', 'periods'], queryKey: ['attendance', 'meta', 'periods'],
queryFn: async () => { queryFn: async () =>
try { validateResponse<AttendancePeriodConfigItem[]>(
return validateResponse<AttendancePeriodConfigItem[]>( attendancePeriodsSchema,
attendancePeriodsSchema, await api.get<AttendancePeriodConfigItem[]>('/attendance-period-configs'),
await api.get<AttendancePeriodConfigItem[]>('/attendance-period-configs'), ),
);
} catch {
return DEFAULT_ATTENDANCE_PERIODS;
}
},
}); });
const { const {
data: scheduleOptions = [], data: scheduleOptions = [],
@@ -114,18 +102,13 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
queryKey: ['attendance', 'schedules', classId, attendanceDate], queryKey: ['attendance', 'schedules', classId, attendanceDate],
enabled: !!classId && !!attendanceDate, enabled: !!classId && !!attendanceDate,
queryFn: async () => { queryFn: async () => {
try { if (!attendanceDate) return [];
if (!attendanceDate) return []; return validateResponse<HistoryScheduleOption[]>(
return validateResponse<HistoryScheduleOption[]>( attendanceScheduleOptionsSchema,
attendanceScheduleOptionsSchema, await api.get<HistoryScheduleOption[]>('/attendance-records/schedules', {
await api.get<HistoryScheduleOption[]>('/attendance-records/schedules', { params: { classId, date: attendanceDate.format('YYYY-MM-DD') },
params: { classId, date: attendanceDate.format('YYYY-MM-DD') }, }),
}), );
);
} catch (error: unknown) {
message.error(getErrorMessage(error, '加载班级科目失败'));
return [];
}
}, },
}); });
const scheduleOptionsLoading = scheduleOptionsFetching; const scheduleOptionsLoading = scheduleOptionsFetching;
@@ -187,13 +170,24 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
} }
}; };
const resetPeriodConfig = async () => { const resetPeriodConfig = () => {
try { modal.confirm({
await resetPeriodConfigMutation.mutateAsync(); title: '恢复默认考勤时段?',
message.success('已恢复默认考勤时段'); content: '当前自定义的考勤时段配置将被系统默认值覆盖,此操作不可撤销。',
} catch { okText: '恢复默认',
// 错误提示由 useApiMutation 统一处理 okButtonProps: { danger: true },
} cancelText: '取消',
onOk: async () => {
try {
const data = await resetPeriodConfigMutation.mutateAsync();
// 同步回填表单,避免界面仍显示旧配置、用户再点保存把旧值写回
periodForm.setFieldsValue({ periods: data });
message.success('已恢复默认考勤时段');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
}; };
const buildParams = useCallback( const buildParams = useCallback(
@@ -216,22 +210,18 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery<DingTalkSyncStatus | null>({ const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery<DingTalkSyncStatus | null>({
queryKey: ['attendance', 'sync-status'], queryKey: ['attendance', 'sync-status'],
queryFn: async () => { queryFn: async () =>
try { validateResponse<DingTalkSyncStatus>(
return validateResponse<DingTalkSyncStatus>( dingTalkSyncStatusSchema,
dingTalkSyncStatusSchema, await api.get<DingTalkSyncStatus>('/attendance-records/dingtalk-sync-status'),
await api.get<DingTalkSyncStatus>('/attendance-records/dingtalk-sync-status'), ),
);
} catch {
return null;
}
},
}); });
const loadSyncStatus = useCallback(() => refetchSyncStatus(), [refetchSyncStatus]); const loadSyncStatus = useCallback(() => refetchSyncStatus(), [refetchSyncStatus]);
const { const {
data: recordQuery = { records: [], total: 0, summary: EMPTY_SUMMARY }, data: recordQuery = { records: [], total: 0, summary: EMPTY_SUMMARY },
isFetching: recordsFetching, isFetching: recordsFetching,
isError: recordsError,
refetch: refetchRecords, refetch: refetchRecords,
} = useQuery<{ } = useQuery<{
records: AttendanceRecordItem[]; records: AttendanceRecordItem[];
@@ -240,32 +230,27 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
}>({ }>({
queryKey: recordQueryKey, queryKey: recordQueryKey,
queryFn: async () => { queryFn: async () => {
try { const [recordData, summaryData] = await Promise.all([
const [recordData, summaryData] = await Promise.all([ api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', {
api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { params: buildParams(true),
params: buildParams(true), }),
}), api.get<AttendanceSummary>('/attendance-records/summary', {
api.get<AttendanceSummary>('/attendance-records/summary', { params: buildParams(false),
params: buildParams(false), }),
}), ]);
]); const validatedRecords = validateResponse<{
const validatedRecords = validateResponse<{ list: AttendanceRecordItem[];
list: AttendanceRecordItem[]; total: number;
total: number; }>(attendanceRecordsResponseSchema, recordData);
}>(attendanceRecordsResponseSchema, recordData); const validatedSummary = validateResponse<AttendanceSummary>(
const validatedSummary = validateResponse<AttendanceSummary>( attendanceSummarySchema,
attendanceSummarySchema, summaryData,
summaryData, );
); return {
return { records: validatedRecords.list,
records: validatedRecords.list, total: validatedRecords.total,
total: validatedRecords.total, summary: { ...EMPTY_SUMMARY, ...validatedSummary },
summary: { ...EMPTY_SUMMARY, ...validatedSummary }, };
};
} catch (error: unknown) {
message.error(getErrorMessage(error, '加载学生考勤失败'));
return { records: [], total: 0, summary: EMPTY_SUMMARY };
}
}, },
}); });
const records = recordQuery.records; const records = recordQuery.records;
@@ -532,27 +517,35 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
alerts={alerts} alerts={alerts}
/> />
<AttendanceAdminWorkspace {recordsError ? (
metricFilter={metricFilter} <QueryErrorState
studentSearch={studentSearch} title="学生考勤数据加载失败"
onSearchChange={setStudentSearch} description="请检查网络后重试。"
onExport={handleExport} onRetry={() => void refetchRecords()}
visibleStudents={visibleStudents} />
loading={loading} ) : (
selectedStudentId={selectedStudent?.studentId} <AttendanceAdminWorkspace
onSelectStudent={setSelectedStudent} metricFilter={metricFilter}
sortAttendanceRecords={sortAttendanceRecords} studentSearch={studentSearch}
sessionMap={sessionMap} onSearchChange={setStudentSearch}
records={records} onExport={handleExport}
columns={columns} visibleStudents={visibleStudents}
page={page} loading={loading}
pageSize={pageSize} selectedStudentId={selectedStudent?.studentId}
total={total} onSelectStudent={setSelectedStudent}
onPageChange={(nextPage, nextPageSize) => { sortAttendanceRecords={sortAttendanceRecords}
setPage(nextPage); sessionMap={sessionMap}
setPageSize(nextPageSize); records={records}
}} columns={columns}
/> page={page}
pageSize={pageSize}
total={total}
onPageChange={(nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
}}
/>
)}
<StudentDetailDrawer <StudentDetailDrawer
student={selectedStudent} student={selectedStudent}

View File

@@ -11,8 +11,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message'; import { QueryErrorState } from '../../components/QueryState';
import { getErrorMessage } from '../../utils/error';
import { import {
canPullAttendance, canPullAttendance,
getSchedulePhase, getSchedulePhase,
@@ -41,21 +40,18 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c
const { const {
data: workspace, data: workspace,
isLoading,
isFetching, isFetching,
isPending,
isError,
refetch, refetch,
} = useQuery<TeacherWorkspaceData | null>({ } = useQuery<TeacherWorkspaceData | null>({
queryKey: ['attendance', 'workspace'], queryKey: ['attendance', 'workspace'],
queryFn: async () => { queryFn: async () => {
try { return await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace');
return await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace');
} catch (error: unknown) {
message.error(getErrorMessage(error, '加载今日课程失败'));
return null;
}
}, },
}); });
const loading = isLoading || isFetching; // isPending 覆盖自动重试的退避窗口,避免「加载失败/重试中」短暂闪现为空态
const loading = isPending || isFetching;
const loadWorkspace = useCallback(() => refetch(), [refetch]); const loadWorkspace = useCallback(() => refetch(), [refetch]);
const classNameById = useMemo( const classNameById = useMemo(
@@ -124,7 +120,15 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c
</div> </div>
<Spin spinning={loading}> <Spin spinning={loading}>
{schedules.length === 0 ? ( {isError ? (
<Card className="attendance-empty-card">
<QueryErrorState
title="课程数据加载失败"
description="请检查网络后点击重试;若持续失败请联系管理员。"
onRetry={() => void loadWorkspace()}
/>
</Card>
) : schedules.length === 0 ? (
<Card className="attendance-empty-card"> <Card className="attendance-empty-card">
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}

View File

@@ -9,6 +9,7 @@ import { PlusOutlined } from '@ant-design/icons';
import api from '../api'; import api from '../api';
import PermissionButton from '../components/PermissionButton'; import PermissionButton from '../components/PermissionButton';
import EditableCell from '../components/EditableCell'; import EditableCell from '../components/EditableCell';
import { QueryErrorState } from '../components/QueryState';
import { message } from '../ui/app-message'; import { message } from '../ui/app-message';
interface ClassroomOption { interface ClassroomOption {
@@ -44,25 +45,22 @@ const AttendanceDevicesPage: React.FC = () => {
data: fetchResult = { devices: [], classrooms: [] }, data: fetchResult = { devices: [], classrooms: [] },
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<{ devices: AttendanceDeviceRow[]; classrooms: ClassroomOption[] }>({ } = useQuery<{ devices: AttendanceDeviceRow[]; classrooms: ClassroomOption[] }>({
queryKey: ['attendance-devices'], queryKey: ['attendance-devices'],
queryFn: async () => { queryFn: async () => {
try { const [devices, classroomList] = await Promise.all([
const [devices, classroomList] = await Promise.all([ api.get<AttendanceDeviceRow[]>('/attendance-devices'),
api.get<AttendanceDeviceRow[]>('/attendance-devices'), api.get<ClassroomOption[]>('/classrooms'),
api.get<ClassroomOption[]>('/classrooms'), ]);
]); return {
return { devices: validateResponse<AttendanceDeviceRow[]>(attendanceDevicesSchema, devices),
devices: validateResponse<AttendanceDeviceRow[]>(attendanceDevicesSchema, devices), classrooms: validateResponse<ClassroomOption[]>(
classrooms: validateResponse<ClassroomOption[]>( classroomOptionsSchema,
classroomOptionsSchema, classroomList,
classroomList, ).filter((item: any) => item.status !== 'archived'),
).filter((item: any) => item.status !== 'archived'), };
};
} catch (error: any) {
message.error(error?.message || '加载考勤机绑定失败');
return { devices: [], classrooms: [] };
}
}, },
}); });
const data = fetchResult.devices; const data = fetchResult.devices;
@@ -307,14 +305,22 @@ const AttendanceDevicesPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</div> </div>
<Table<AttendanceDeviceRow> {isError ? (
rowKey="id" <QueryErrorState
columns={columns} title="考勤机数据加载失败"
dataSource={filteredData} description="请检查网络后重试。"
loading={loading} onRetry={() => void refetch()}
locale={{ emptyText: <Empty description="暂无考勤机绑定" /> }} />
pagination={{ defaultPageSize: 20, showSizeChanger: true }} ) : (
/> <Table<AttendanceDeviceRow>
rowKey="id"
columns={columns}
dataSource={filteredData}
loading={loading}
locale={{ emptyText: <Empty description="暂无考勤机绑定" /> }}
pagination={{ defaultPageSize: 20, showSizeChanger: true }}
/>
)}
<Modal <Modal
title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'} title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'}
open={modalOpen} open={modalOpen}

View File

@@ -24,6 +24,9 @@ import {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { QueryErrorState } from '../../components/QueryState';
import { NextStepHint } from '../../components/NextStepHint';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { buildBillPrintHtml, type BillPrintData } from './bill-print'; import { buildBillPrintHtml, type BillPrintData } from './bill-print';
@@ -64,26 +67,27 @@ const BillsPage: React.FC = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [batchLoading, setBatchLoading] = useState(false); const [batchLoading, setBatchLoading] = useState(false);
// 生成账单成功后的「下一步」引导提示
const [billGeneratedHint, setBillGeneratedHint] = useState(false);
const { const {
data: bills = [], data: bills = [],
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery({ } = useQuery({
queryKey: ['bills', filterStatus, filterExpenseType], queryKey: ['bills', filterStatus, filterExpenseType],
queryFn: async () => { queryFn: async () => {
try { const params: Record<string, string | undefined> = {};
const params: Record<string, string | undefined> = {}; if (filterStatus) params.status = filterStatus;
if (filterStatus) params.status = filterStatus; if (filterExpenseType) params.expenseType = filterExpenseType;
if (filterExpenseType) params.expenseType = filterExpenseType; return validateResponse<unknown[]>(billsSchema, await api.get('/bills', { params }));
return validateResponse<unknown[]>(billsSchema, await api.get('/bills', { params }));
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
}, },
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新账单列表
useVisibleRefetch(['bills']);
const generateMutation = useApiMutation( const generateMutation = useApiMutation(
async (payload: { operationId: string; billingMonth: string }) => async (payload: { operationId: string; billingMonth: string }) =>
@@ -122,8 +126,8 @@ const BillsPage: React.FC = () => {
}, [bills, searchText, filterStatus]); }, [bills, searchText, filterStatus]);
const handleGenerate = async () => { const handleGenerate = async () => {
setSaving(true);
const values = await generateForm.validateFields(); const values = await generateForm.validateFields();
setSaving(true);
try { try {
const res: any = await generateMutation.mutateAsync({ const res: any = await generateMutation.mutateAsync({
operationId: newOperationId(), operationId: newOperationId(),
@@ -132,6 +136,7 @@ const BillsPage: React.FC = () => {
message.success(res.message || '生成成功'); message.success(res.message || '生成成功');
setGenerateModal(false); setGenerateModal(false);
generateForm.resetFields(); generateForm.resetFields();
setBillGeneratedHint(true);
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} finally { } finally {
@@ -470,19 +475,42 @@ const BillsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</Space> </Space>
</div> </div>
<Table {billGeneratedHint && (
scroll={{ x: 1400 }} <NextStepHint
columns={columns} title="账单已生成"
dataSource={filteredBills} description="请核对账单明细,确认后标记已付,完成「住宿→计费」闭环。"
rowKey="id" action={{
loading={loading} label: '筛选待确认账单',
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} onClick: () => {
locale={{ emptyText: <Empty description="暂无数据" /> }} // 账单生成后即为 unpaid待支付状态
rowSelection={{ setFilterStatus('unpaid');
selectedRowKeys: selectedRows, setBillGeneratedHint(false);
onChange: (keys) => setSelectedRows(keys as number[]), },
}} }}
/> onClose={() => setBillGeneratedHint(false)}
/>
)}
{isError ? (
<QueryErrorState
title="账单数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<Table
scroll={{ x: 1400 }}
columns={columns}
dataSource={filteredBills}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedRows,
onChange: (keys) => setSelectedRows(keys as number[]),
}}
/>
)}
<Modal <Modal
title="生成账单" title="生成账单"

View File

@@ -1,6 +1,6 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { Button, Card, Form, Space, Tabs, Tag } from 'antd'; import { Button, Card, Form, Space, Spin, Tabs, Tag } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
@@ -8,6 +8,7 @@ import { message } from '../../ui/app-message';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { TeacherCandidateUser } from './teacher-candidate'; import type { TeacherCandidateUser } from './teacher-candidate';
import { getErrorMessage } from '../../utils/error'; import { getErrorMessage } from '../../utils/error';
import { QueryErrorState } from '../../components/QueryState';
import { import {
ClassAttendanceTab, ClassAttendanceTab,
ClassInfoTab, ClassInfoTab,
@@ -51,6 +52,7 @@ const ClassDetailPage: React.FC = () => {
data: detailResult = { detail: null, students: [], teachers: [] }, data: detailResult = { detail: null, students: [], teachers: [] },
isLoading: detailLoading, isLoading: detailLoading,
isFetching: detailFetching, isFetching: detailFetching,
isError: detailError,
refetch: refetchDetail, refetch: refetchDetail,
} = useQuery<{ } = useQuery<{
detail: ClassDetail | null; detail: ClassDetail | null;
@@ -59,13 +61,8 @@ const ClassDetailPage: React.FC = () => {
}>({ }>({
queryKey: ['classes', 'detail', id], queryKey: ['classes', 'detail', id],
queryFn: async () => { queryFn: async () => {
try { const res = (await api.get(`/classes/${id}`)) as ClassDetail;
const res = (await api.get(`/classes/${id}`)) as ClassDetail; return { detail: res, students: res.students || [], teachers: res.teachers || [] };
return { detail: res, students: res.students || [], teachers: res.teachers || [] };
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败'));
return { detail: null, students: [], teachers: [] };
}
}, },
}); });
const detail = detailResult.detail; const detail = detailResult.detail;
@@ -74,52 +71,50 @@ const ClassDetailPage: React.FC = () => {
const loading = detailLoading || detailFetching; const loading = detailLoading || detailFetching;
const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]); const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]);
const { data: allUsers = [], refetch: refetchUsers } = useQuery<TeacherCandidateUser[]>({ const {
data: allUsers = [],
isError: allUsersError,
refetch: refetchUsers,
} = useQuery<TeacherCandidateUser[]>({
queryKey: ['rbac', 'users', 'all'], queryKey: ['rbac', 'users', 'all'],
queryFn: async () => { queryFn: async () => {
try { return (await api.get('/rbac/users')) as TeacherCandidateUser[];
return (await api.get('/rbac/users')) as TeacherCandidateUser[];
} catch {
return [];
}
}, },
}); });
const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]); const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]);
const { data: schedules = [] } = useQuery<ClassScheduleItem[]>({ const {
data: schedules = [],
isError: schedulesError,
refetch: refetchSchedules,
} = useQuery<ClassScheduleItem[]>({
queryKey: ['classes', 'schedule', id, scheduleDateRange], queryKey: ['classes', 'schedule', id, scheduleDateRange],
queryFn: async () => { queryFn: async () => {
if (!id) return []; if (!id) return [];
try { const params: Record<string, string> = {};
const params: Record<string, string> = {}; if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');
if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD'); if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');
if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD'); return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || [];
return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || [];
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载课表失败'));
return [];
}
}, },
}); });
const { data: attendanceSummary = null } = useQuery<AttendanceSummary | null>({ const {
data: attendanceSummary = null,
isError: attendanceSummaryError,
refetch: refetchAttendanceSummary,
} = useQuery<AttendanceSummary | null>({
queryKey: ['classes', 'attendance-summary', id, attendanceDateRange], queryKey: ['classes', 'attendance-summary', id, attendanceDateRange],
queryFn: async () => { queryFn: async () => {
if (!id) return null; if (!id) return null;
try { const params: Record<string, string> = {};
const params: Record<string, string> = {}; if (attendanceDateRange?.[0])
if (attendanceDateRange?.[0]) params.startDate = attendanceDateRange[0].format('YYYY-MM-DD');
params.startDate = attendanceDateRange[0].format('YYYY-MM-DD'); if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD');
if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD'); return (
return ( (await api.get<AttendanceSummary>(`/classes/${id}/attendance-summary`, {
(await api.get<AttendanceSummary>(`/classes/${id}/attendance-summary`, { params,
params, })) || null
})) || null );
);
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载出勤汇总失败'));
return null;
}
}, },
}); });
@@ -221,7 +216,25 @@ const ClassDetailPage: React.FC = () => {
const getTeacherName = (teacher: ClassTeacher) => const getTeacherName = (teacher: ClassTeacher) =>
allUsers.find((user) => user.id === teacher.userId)?.name?.trim() || '-'; allUsers.find((user) => user.id === teacher.userId)?.name?.trim() || '-';
if (!detail) return null; if (!detail) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (detailError) {
return (
<QueryErrorState
title="班级详情加载失败"
description="请检查网络后重试。"
onRetry={() => void refetchDetail()}
/>
);
}
return null;
}
return ( return (
<Card <Card
@@ -289,7 +302,13 @@ const ClassDetailPage: React.FC = () => {
{ {
key: 'teachers', key: 'teachers',
label: `教师 (${teachers.length})`, label: `教师 (${teachers.length})`,
children: ( children: allUsersError ? (
<QueryErrorState
title="可添加教师加载失败"
description="请检查网络后重试。"
onRetry={() => void fetchUsers()}
/>
) : (
<ClassTeachersTab <ClassTeachersTab
teachers={teachers} teachers={teachers}
allUsers={allUsers} allUsers={allUsers}
@@ -311,7 +330,13 @@ const ClassDetailPage: React.FC = () => {
{ {
key: 'schedule', key: 'schedule',
label: '课表', label: '课表',
children: ( children: schedulesError ? (
<QueryErrorState
title="课表加载失败"
description="请检查网络后重试。"
onRetry={() => void refetchSchedules()}
/>
) : (
<ClassScheduleTab <ClassScheduleTab
schedules={schedules} schedules={schedules}
scheduleDateRange={scheduleDateRange} scheduleDateRange={scheduleDateRange}
@@ -322,7 +347,13 @@ const ClassDetailPage: React.FC = () => {
{ {
key: 'attendance-summary', key: 'attendance-summary',
label: '出勤汇总', label: '出勤汇总',
children: ( children: attendanceSummaryError ? (
<QueryErrorState
title="出勤汇总加载失败"
description="请检查网络后重试。"
onRetry={() => void refetchAttendanceSummary()}
/>
) : (
<ClassAttendanceTab <ClassAttendanceTab
attendanceSummary={attendanceSummary} attendanceSummary={attendanceSummary}
attendanceDateRange={attendanceDateRange} attendanceDateRange={attendanceDateRange}

View File

@@ -30,6 +30,8 @@ import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell'; import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
interface ClassItem { interface ClassItem {
id: number; id: number;
@@ -93,25 +95,24 @@ const ClassesPage: React.FC = () => {
data = [], data = [],
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<ClassItem[]>({ } = useQuery<ClassItem[]>({
queryKey: ['classes', filterStatus, filterType, showArchived], queryKey: ['classes', filterStatus, filterType, showArchived],
queryFn: async () => { queryFn: async () => {
try { const params: Record<string, string | boolean | undefined> = {};
const params: Record<string, string | boolean | undefined> = {}; if (filterStatus) params.status = filterStatus;
if (filterStatus) params.status = filterStatus; if (filterType) params.classType = filterType;
if (filterType) params.classType = filterType; params.isArchived = showArchived;
params.isArchived = showArchived; return validateResponse<ClassItem[]>(
return validateResponse<ClassItem[]>( classesSchema,
classesSchema, await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>),
await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>), );
);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
}, },
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
useVisibleRefetch(['classes']);
const saveMutation = useApiMutation( const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) => async (payload: Record<string, unknown>) =>
@@ -430,19 +431,27 @@ const ClassesPage: React.FC = () => {
/> />
</span> </span>
</Space> </Space>
<Table<ClassItem> {isError ? (
columns={columns} <QueryErrorState
dataSource={filtered} title="班级数据加载失败"
rowKey="id" description="请检查网络后重试。"
loading={loading} onRetry={() => void refetch()}
locale={{ emptyText: <Empty description="暂无数据" /> }} />
pagination={{ ) : (
defaultPageSize: 20, <Table<ClassItem>
showSizeChanger: true, columns={columns}
pageSizeOptions: [20, 50, 100], dataSource={filtered}
}} rowKey="id"
scroll={{ x: 1100 }} loading={loading}
/> locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
scroll={{ x: 1100 }}
/>
)}
<Modal <Modal
title={editing ? '编辑班级' : '创建班级'} title={editing ? '编辑班级' : '创建班级'}

View File

@@ -15,8 +15,10 @@ import dayjs, { Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { QueryErrorState } from '../../components/QueryState';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
import { getErrorMessage } from '../../utils/error'; import { getErrorMessage } from '../../utils/error';
@@ -52,21 +54,18 @@ const ClassroomRentalsPage: React.FC = () => {
data = [], data = [],
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<any[]>({ } = useQuery<any[]>({
queryKey: ['classroom-rentals', filterMonth], queryKey: ['classroom-rentals', filterMonth],
queryFn: async () => { queryFn: async () => {
try { const params: any = {};
const params: any = {}; if (filterMonth) params.month = filterMonth.format('YYYY-MM');
if (filterMonth) params.month = filterMonth.format('YYYY-MM'); params.includeEnded = true;
params.includeEnded = true; return validateResponse<any[]>(
return validateResponse<any[]>( rentalsSchema,
rentalsSchema, await api.get('/classroom-rentals', { params }),
await api.get('/classroom-rentals', { params }), );
);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
}, },
}); });
const { const {
@@ -93,6 +92,8 @@ const ClassroomRentalsPage: React.FC = () => {
const classrooms = meta.classrooms; const classrooms = meta.classrooms;
const organizations = meta.organizations; const organizations = meta.organizations;
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
useVisibleRefetch(['classroom-rentals']);
const saveMutation = useApiMutation( const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) => async (payload: Record<string, unknown>) =>
@@ -399,22 +400,30 @@ const ClassroomRentalsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</div> </div>
<RentalTable {isError ? (
data={filteredData} <QueryErrorState
loading={loading} title="租赁订单加载失败"
classrooms={classrooms} description="请检查网络后重试。"
organizations={organizations} onRetry={() => void refetch()}
canPurgeRental={canPurgeRental} />
hasPermission={hasPermission} ) : (
onSaveCell={saveCell} <RentalTable
onEdit={openEdit} data={filteredData}
onAction={handleRentalAction} loading={loading}
onArchive={handleDelete} classrooms={classrooms}
onPurge={handlePurge} organizations={organizations}
onDownloadContract={handleDownloadContract} canPurgeRental={canPurgeRental}
onDeleteContract={handleDeleteContract} hasPermission={hasPermission}
onUploadContract={handleUploadContract} onSaveCell={saveCell}
/> onEdit={openEdit}
onAction={handleRentalAction}
onArchive={handleDelete}
onPurge={handlePurge}
onDownloadContract={handleDownloadContract}
onDeleteContract={handleDeleteContract}
onUploadContract={handleUploadContract}
/>
)}
<Modal <Modal
title={editing ? '编辑租赁' : '新增租赁'} title={editing ? '编辑租赁' : '新增租赁'}
open={modalOpen} open={modalOpen}

View File

@@ -22,6 +22,8 @@ import api from '../../api';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { getErrorMessage } from '../../utils/error'; import { getErrorMessage } from '../../utils/error';
import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
interface ScheduleData { interface ScheduleData {
year: number; year: number;
@@ -40,23 +42,19 @@ const ClassroomSchedulePage: React.FC = () => {
const [month, setMonth] = useState<Dayjs>(dayjs()); const [month, setMonth] = useState<Dayjs>(dayjs());
const [detailModal, setDetailModal] = useState<any>(null); const [detailModal, setDetailModal] = useState<any>(null);
const { data, isLoading, isFetching } = useQuery<ScheduleData | null>({ const { data, isLoading, isFetching, isError, refetch } = useQuery<ScheduleData | null>({
queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()], queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()],
queryFn: async () => { queryFn: async () =>
try { validateResponse<ScheduleData | null>(
return validateResponse<ScheduleData | null>( classroomScheduleSchema,
classroomScheduleSchema, await api.get('/classroom-rentals/schedule', {
await api.get('/classroom-rentals/schedule', { params: { year: month.year(), month: month.month() + 1 },
params: { year: month.year(), month: month.month() + 1 }, }),
}), ),
);
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return null;
}
},
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新排期数据
useVisibleRefetch(['classroom-rentals', 'schedule']);
// 按楼栋+楼层分组教室 // 按楼栋+楼层分组教室
const groups = useMemo(() => { const groups = useMemo(() => {
@@ -135,208 +133,218 @@ const ClassroomSchedulePage: React.FC = () => {
</Space> </Space>
</div> </div>
{/* 统计卡片 */} {isError ? (
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}> <QueryErrorState
<Col xs={12} sm={6}> title="教室排期加载失败"
<Card size="small"> description="请检查网络后重试。"
<Statistic title="教室总数" value={data?.classrooms.length || 0} /> onRetry={() => void refetch()}
</Card> />
</Col> ) : (
<Col xs={12} sm={6}> <>
<Card size="small"> {/* 统计卡片 */}
<Statistic title="本月天数" value={data?.days || 0} /> <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
</Card> <Col xs={12} sm={6}>
</Col> <Card size="small">
<Col xs={12} sm={6}> <Statistic title="教室总数" value={data?.classrooms.length || 0} />
<Card size="small"> </Card>
<Statistic title="总占用天数" value={overall.rented} suffix={`/${overall.total}`} /> </Col>
</Card> <Col xs={12} sm={6}>
</Col> <Card size="small">
<Col xs={12} sm={6}> <Statistic title="本月天数" value={data?.days || 0} />
<Card size="small"> </Card>
<Statistic </Col>
title="整体占用率" <Col xs={12} sm={6}>
value={overall.rate} <Card size="small">
suffix="%" <Statistic title="总占用天数" value={overall.rented} suffix={`/${overall.total}`} />
styles={{ </Card>
value: { </Col>
color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600', <Col xs={12} sm={6}>
}, <Card size="small">
}} <Statistic
/> title="整体占用率"
</Card> value={overall.rate}
</Col> suffix="%"
</Row> styles={{
value: {
color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600',
},
}}
/>
</Card>
</Col>
</Row>
{/* 图例 */} {/* 图例 */}
{data && ( {data && (
<Card size="small" style={{ marginBottom: 16 }} title="图例"> <Card size="small" style={{ marginBottom: 16 }} title="图例">
<Space wrap> <Space wrap>
<Tag color="#52c41a"></Tag> <Tag color="#52c41a"></Tag>
{data.organizations.map((t) => ( {data.organizations.map((t) => (
<Tag <Tag
key={t.id} key={t.id}
color={t.color} color={t.color}
style={{ background: t.color, color: '#fff', borderColor: t.color }} style={{ background: t.color, color: '#fff', borderColor: t.color }}
> >
{t.name} () {t.name} ()
</Tag> </Tag>
))} ))}
<Tag color="#d9d9d9" style={{ color: '#999' }}> <Tag color="#d9d9d9" style={{ color: '#999' }}>
</Tag> </Tag>
</Space> </Space>
</Card> </Card>
)} )}
<Spin spinning={loading}> <Spin spinning={loading}>
{!data || data.classrooms.length === 0 ? ( {!data || data.classrooms.length === 0 ? (
<Empty description="暂无教室数据" /> <Empty description="暂无教室数据" />
) : ( ) : (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
{groups.map((group) => ( {groups.map((group) => (
<Card <Card
key={group.name} key={group.name}
size="small" size="small"
title={group.name} title={group.name}
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
styles={{ body: { padding: 0 } }} styles={{ body: { padding: 0 } }}
> >
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead> <thead>
<tr style={{ background: '#fafafa' }}> <tr style={{ background: '#fafafa' }}>
<th <th
style={{
position: 'sticky',
left: 0,
background: '#fafafa',
zIndex: 2,
padding: '8px',
border: '1px solid #f0f0f0',
minWidth: 120,
textAlign: 'left',
}}
>
</th>
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 60 }}>
</th>
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 70 }}>
</th>
{Array.from({ length: data.days }, (_, i) => i + 1).map((d) => (
<th
key={d}
style={{
padding: '8px 4px',
border: '1px solid #f0f0f0',
minWidth: 26,
textAlign: 'center',
}}
>
{d}
</th>
))}
</tr>
</thead>
<tbody>
{group.classrooms.map((c) => {
const sum = data.summary[c.id] || {
rentedDays: 0,
totalDays: data.days,
occupancyRate: 0,
};
return (
<tr key={c.id}>
<td
style={{ style={{
position: 'sticky', position: 'sticky',
left: 0, left: 0,
background: '#fff', background: '#fafafa',
zIndex: 1, zIndex: 2,
padding: '6px 8px', padding: '8px',
border: '1px solid #f0f0f0', border: '1px solid #f0f0f0',
fontWeight: 500, minWidth: 120,
textAlign: 'left',
}} }}
> >
{c.name}
</td> </th>
<td <th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 60 }}>
style={{
padding: '6px', </th>
border: '1px solid #f0f0f0', <th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 70 }}>
textAlign: 'center',
}} </th>
> {Array.from({ length: data.days }, (_, i) => i + 1).map((d) => (
{c.roomType} <th
</td> key={d}
<td style={{
style={{ padding: '8px 4px',
padding: '6px', border: '1px solid #f0f0f0',
border: '1px solid #f0f0f0', minWidth: 26,
textAlign: 'center', textAlign: 'center',
color: }}
sum.occupancyRate > 0.7 >
? '#cf1322' {d}
: sum.occupancyRate > 0.4 </th>
? '#fa8c16' ))}
: '#3f8600', </tr>
}} </thead>
> <tbody>
{Math.round(sum.occupancyRate * 100)}% {group.classrooms.map((c) => {
</td> const sum = data.summary[c.id] || {
{Array.from({ length: data.days }, (_, i) => i + 1).map((d) => { rentedDays: 0,
const cell = data.matrix[c.id]?.[d]; totalDays: data.days,
const isInternal = cell?.scheduleType === 'INTERNAL'; occupancyRate: 0,
const isRental = cell?.scheduleType === 'RENTAL'; };
return ( return (
<tr key={c.id}>
<td <td
key={d}
onClick={() => {
if (isRental) showDetail(cell.rentalId);
}}
style={{ style={{
padding: 0, position: 'sticky',
left: 0,
background: '#fff',
zIndex: 1,
padding: '6px 8px',
border: '1px solid #f0f0f0',
fontWeight: 500,
}}
>
{c.name}
</td>
<td
style={{
padding: '6px',
border: '1px solid #f0f0f0', border: '1px solid #f0f0f0',
background: cell?.color || '#fff',
height: 26,
cursor: isRental ? 'pointer' : 'default',
textAlign: 'center', textAlign: 'center',
}} }}
> >
{cell && ( {c.roomType}
<Tooltip
title={
isInternal
? `${cell.className} · ${cell.subject}\n${cell.teacherName} · ${cell.startTime}-${cell.endTime}`
: `${cell.organizationName}${cell.hasContract ? ' · 有合同' : ''}`
}
>
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
{isInternal ? (
<ReadOutlined style={{ fontSize: 12 }} />
) : cell.hasContract ? (
<FileTextOutlined style={{ fontSize: 12 }} />
) : (
''
)}
</span>
</Tooltip>
)}
</td> </td>
); <td
})} style={{
</tr> padding: '6px',
); border: '1px solid #f0f0f0',
})} textAlign: 'center',
</tbody> color:
</table> sum.occupancyRate > 0.7
</Card> ? '#cf1322'
))} : sum.occupancyRate > 0.4
</div> ? '#fa8c16'
)} : '#3f8600',
</Spin> }}
>
{Math.round(sum.occupancyRate * 100)}%
</td>
{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 (
<td
key={d}
onClick={() => {
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 && (
<Tooltip
title={
isInternal
? `${cell.className} · ${cell.subject}\n${cell.teacherName} · ${cell.startTime}-${cell.endTime}`
: `${cell.organizationName}${cell.hasContract ? ' · 有合同' : ''}`
}
>
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
{isInternal ? (
<ReadOutlined style={{ fontSize: 12 }} />
) : cell.hasContract ? (
<FileTextOutlined style={{ fontSize: 12 }} />
) : (
''
)}
</span>
</Tooltip>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</Card>
))}
</div>
)}
</Spin>
</>
)}
<Modal <Modal
title="租赁详情" title="租赁详情"

View File

@@ -29,9 +29,11 @@ import {
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell'; import EditableCell from '../../components/EditableCell';
import { QueryErrorState } from '../../components/QueryState';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useUserStore } from '../../store/user/userStore'; import { useUserStore } from '../../store/user/userStore';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
available: { text: '可用', color: 'green' }, available: { text: '可用', color: 'green' },
@@ -70,21 +72,19 @@ const ClassroomsPage: React.FC = () => {
data = [], data = [],
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<any[]>({ } = useQuery<any[]>({
queryKey: ['classrooms', showArchived], queryKey: ['classrooms', showArchived],
queryFn: async () => { queryFn: async () =>
try { validateResponse<any[]>(
return validateResponse<any[]>( classroomsSchema,
classroomsSchema, await api.get('/classrooms', { params: { includeArchived: showArchived } }),
await api.get('/classrooms', { params: { includeArchived: showArchived } }), ),
);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
},
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
useVisibleRefetch(['classrooms']);
const saveMutation = useApiMutation( const saveMutation = useApiMutation(
async (values: Record<string, unknown>) => async (values: Record<string, unknown>) =>
@@ -514,20 +514,28 @@ const ClassroomsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</Space> </Space>
</div> </div>
<Table {isError ? (
scroll={{ x: 1100 }} <QueryErrorState
columns={columns} title="教室列表加载失败"
dataSource={filteredData} description="请检查网络后重试。"
rowKey="id" onRetry={() => void refetch()}
loading={loading} />
locale={{ emptyText: <Empty description="暂无数据" /> }} ) : (
pagination={{ <Table
defaultPageSize: 20, scroll={{ x: 1100 }}
showSizeChanger: true, columns={columns}
pageSizeOptions: [20, 50, 100], dataSource={filteredData}
showTotal: (total) => `${total}`, rowKey="id"
}} loading={loading}
/> locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total}`,
}}
/>
)}
<Modal <Modal
title={editing ? '编辑教室' : '添加教室'} title={editing ? '编辑教室' : '添加教室'}
open={modalOpen} open={modalOpen}

View File

@@ -78,50 +78,100 @@ const DashboardPage: React.FC = () => {
}>({ }>({
queryKey: ['dashboard', period], queryKey: ['dashboard', period],
queryFn: async () => { queryFn: async () => {
try { // 各数据接口独立加载:单个接口失败只影响对应模块,避免整页数据被清零
const [s, rr, cr, g, co, cu] = await Promise.all([ const settled = await Promise.allSettled([
api.get<DashboardStats>('/dashboard/stats'), api.get<DashboardStats>('/dashboard/stats'),
api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', { api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', {
params: { periodStart: period[0], periodEnd: period[1] }, params: { periodStart: period[0], periodEnd: period[1] },
}), }),
api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>( api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>(
'/dashboard/class-attendance-ranking', '/dashboard/class-attendance-ranking',
), ),
api.get<GanttRoom[]>('/dashboard/gantt', { api.get<GanttRoom[]>('/dashboard/gantt', {
params: { periodStart: period[0], periodEnd: period[1] }, params: { periodStart: period[0], periodEnd: period[1] },
}), }),
api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy'), api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy'),
api.get<ClassroomUtilStats>('/dashboard/classroom-utilization'), api.get<ClassroomUtilStats>('/dashboard/classroom-utilization'),
]); ]);
return { const value = <T,>(r: PromiseSettledResult<T>): T | null =>
stats: validateResponse<DashboardStats>(dashboardStatsSchema, s), r.status === 'fulfilled' ? r.value : null;
roomRanking: validateResponse<Array<{ roomNumber: string; total: string }>>( 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<DashboardStats>(dashboardStatsSchema, s);
} catch (e) {
console.error(e);
}
}
let roomRanking: Array<{ roomNumber: string; total: string }> = [];
const rr = value(settled[1]);
if (rr) {
try {
roomRanking = validateResponse<Array<{ roomNumber: string; total: string }>>(
roomRankingSchema, roomRankingSchema,
rr, 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[]; top: ClassAttendanceRank[];
bottom: ClassAttendanceRank[]; bottom: ClassAttendanceRank[];
}>(classAttendanceRankingSchema, cr), }>(classAttendanceRankingSchema, cr);
ganttData: validateResponse<GanttRoom[]>(ganttRoomsSchema, g), } catch (e) {
classroomOccupancy: validateResponse<ClassroomOccupancy[]>( console.error(e);
}
}
let ganttData: GanttRoom[] = [];
const g = value(settled[3]);
if (g) {
try {
ganttData = validateResponse<GanttRoom[]>(ganttRoomsSchema, g);
} catch (e) {
console.error(e);
}
}
let classroomOccupancy: ClassroomOccupancy[] = [];
const co = value(settled[4]);
if (co) {
try {
classroomOccupancy = validateResponse<ClassroomOccupancy[]>(
classroomOccupanciesSchema, classroomOccupanciesSchema,
co, co,
), );
classroomUtil: validateResponse<ClassroomUtilStats>(classroomUtilStatsSchema, cu), } catch (e) {
}; console.error(e);
} catch (e) { }
console.error(e);
message.error('数据加载失败,请稍后重试');
return {
stats: null,
classRanking: { top: [], bottom: [] },
classroomOccupancy: [],
ganttData: [],
roomRanking: [],
classroomUtil: null,
};
} }
let classroomUtil: ClassroomUtilStats | null = null;
const cu = value(settled[5]);
if (cu) {
try {
classroomUtil = validateResponse<ClassroomUtilStats>(classroomUtilStatsSchema, cu);
} catch (e) {
console.error(e);
}
}
return { stats, classRanking, classroomOccupancy, ganttData, roomRanking, classroomUtil };
}, },
}); });
const stats = fetchResult.stats; const stats = fetchResult.stats;

View File

@@ -408,6 +408,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
onOk={onAddInstallment} onOk={onAddInstallment}
onCancel={onCloseInstallment} onCancel={onCloseInstallment}
okText="确认" okText="确认"
confirmLoading={saving}
> >
<Form form={installmentForm} layout="vertical"> <Form form={installmentForm} layout="vertical">
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}> <Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>

View File

@@ -28,6 +28,8 @@ import {
} from './DepositModals'; } from './DepositModals';
import type { DepositRecord, EligibleStudent } from './DepositModals'; import type { DepositRecord, EligibleStudent } from './DepositModals';
import { DepositTable } from './DepositTable'; import { DepositTable } from './DepositTable';
import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
const DepositsPage: React.FC = () => { const DepositsPage: React.FC = () => {
const { hasPermission } = usePermission(); const { hasPermission } = usePermission();
@@ -55,27 +57,26 @@ const DepositsPage: React.FC = () => {
data: fetchResult = { data: [], students: [] }, data: fetchResult = { data: [], students: [] },
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<{ data: DepositRecord[]; students: DepositStudentLookup[] }>({ } = useQuery<{ data: DepositRecord[]; students: DepositStudentLookup[] }>({
queryKey: ['deposits'], queryKey: ['deposits'],
queryFn: async () => { queryFn: async () => {
try { const [d, s] = await Promise.all([
const [d, s] = await Promise.all([ api.get<DepositRecord[]>('/deposits'),
api.get<DepositRecord[]>('/deposits'), api.get<DepositStudentLookup[]>('/deposits/student-lookups'),
api.get<DepositStudentLookup[]>('/deposits/student-lookups'), ]);
]); return {
return { data: validateResponse<DepositRecord[]>(depositsSchema, d),
data: validateResponse<DepositRecord[]>(depositsSchema, d), students: validateResponse<DepositStudentLookup[]>(depositStudentLookupsSchema, s),
students: validateResponse<DepositStudentLookup[]>(depositStudentLookupsSchema, s), };
};
} catch (e: any) {
message.error(e?.message || '加载失败');
return { data: [], students: [] };
}
}, },
}); });
const data = fetchResult.data; const data = fetchResult.data;
const students = fetchResult.students; const students = fetchResult.students;
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新押金列表
useVisibleRefetch(['deposits']);
const invalidateDeposits: QueryKey[] = [['deposits'], ['deposits', 'eligible']]; const invalidateDeposits: QueryKey[] = [['deposits'], ['deposits', 'eligible']];
const createMutation = useApiMutation( const createMutation = useApiMutation(
@@ -132,6 +133,8 @@ const DepositsPage: React.FC = () => {
const { const {
data: eligibleStudents = [], data: eligibleStudents = [],
isFetching: eligibleFetching, isFetching: eligibleFetching,
isError: eligibleError,
refetch: refetchEligible,
} = useQuery<EligibleStudent[]>({ } = useQuery<EligibleStudent[]>({
queryKey: ['deposits', 'eligible', eligibleRoomType], queryKey: ['deposits', 'eligible', eligibleRoomType],
queryFn: async () => { queryFn: async () => {
@@ -224,6 +227,9 @@ const DepositsPage: React.FC = () => {
const handleBatchRoomTypeChange = (roomType: string) => { const handleBatchRoomTypeChange = (roomType: string) => {
setBatchRoomType(roomType); setBatchRoomType(roomType);
// 切换房型后候选学生列表会变化,重置勾选状态,避免把上一房型的选择提交到新房型
setSelectionTouched(false);
setSelectedEligibleStudentIds([]);
batchForm.setFieldsValue({ batchForm.setFieldsValue({
amount: suggestedDepositByRoomType[roomType] ?? batchForm.getFieldValue('amount') ?? 100, amount: suggestedDepositByRoomType[roomType] ?? batchForm.getFieldValue('amount') ?? 100,
}); });
@@ -231,6 +237,7 @@ const DepositsPage: React.FC = () => {
}; };
const handleCreate = async () => { const handleCreate = async () => {
setSaving(true);
try { try {
const values = await createForm.validateFields(); const values = await createForm.validateFields();
await createMutation.mutateAsync({ await createMutation.mutateAsync({
@@ -244,10 +251,13 @@ const DepositsPage: React.FC = () => {
createForm.resetFields(); createForm.resetFields();
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
} }
}; };
const handleBatchCreate = async () => { const handleBatchCreate = async () => {
setSaving(true);
try { try {
const values = await batchForm.validateFields(); const values = await batchForm.validateFields();
await batchCreateMutation.mutateAsync({ await batchCreateMutation.mutateAsync({
@@ -263,6 +273,8 @@ const DepositsPage: React.FC = () => {
setSelectionTouched(false); setSelectionTouched(false);
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
} }
}; };
@@ -290,6 +302,7 @@ const DepositsPage: React.FC = () => {
const handleAddInstallment = async () => { const handleAddInstallment = async () => {
if (installmentModal == null) return; if (installmentModal == null) return;
setSaving(true);
try { try {
const values = await installmentForm.validateFields(); const values = await installmentForm.validateFields();
await addInstallmentMutation.mutateAsync({ await addInstallmentMutation.mutateAsync({
@@ -304,6 +317,8 @@ const DepositsPage: React.FC = () => {
installmentForm.resetFields(); installmentForm.resetFields();
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
} }
}; };
@@ -426,16 +441,30 @@ const DepositsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</Space> </Space>
</div> </div>
<DepositTable {isError ? (
data={filteredData} <QueryErrorState
loading={loading || (!!filterRoomType && eligibleLoading)} title="押金数据加载失败"
canPurgeDeposit={canPurgeDeposit} description="请检查网络后重试。"
refundForm={refundForm} onRetry={() => void refetch()}
onDetail={(record) => setDetailModal(record)} />
onRefund={(record) => setRefundModal(record)} ) : filterRoomType && eligibleError ? (
onArchive={(id) => archiveMutation.mutateAsync(id)} <QueryErrorState
onPurge={(id) => purgeMutation.mutateAsync(id)} title="可收取押金的学生加载失败"
/> description="请检查网络后重试。"
onRetry={() => void refetchEligible()}
/>
) : (
<DepositTable
data={filteredData}
loading={loading || (!!filterRoomType && eligibleLoading)}
canPurgeDeposit={canPurgeDeposit}
refundForm={refundForm}
onDetail={(record) => setDetailModal(record)}
onRefund={(record) => setRefundModal(record)}
onArchive={(id) => archiveMutation.mutateAsync(id)}
onPurge={(id) => purgeMutation.mutateAsync(id)}
/>
)}
<DepositModals <DepositModals
batchModal={batchModal} batchModal={batchModal}
createModal={createModal} createModal={createModal}

View File

@@ -39,6 +39,8 @@ import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate'; import { validateResponse } from '../../utils/validate';
import { classOptionsSchema, examsSchema } from '../../api/schemas'; import { classOptionsSchema, examsSchema } from '../../api/schemas';
import { getErrorMessage } from '../../utils/error'; import { getErrorMessage } from '../../utils/error';
import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
const ExamsPage: React.FC = () => { const ExamsPage: React.FC = () => {
const { modal } = App.useApp(); const { modal } = App.useApp();
@@ -76,7 +78,7 @@ const ExamsPage: React.FC = () => {
}, },
}); });
const { data = [], isFetching } = useQuery<ExamItem[]>({ const { data = [], isFetching, isError, refetch } = useQuery<ExamItem[]>({
queryKey: [ queryKey: [
'exams', 'exams',
debouncedFilters.keyword, debouncedFilters.keyword,
@@ -85,26 +87,23 @@ const ExamsPage: React.FC = () => {
debouncedFilters.showArchived, debouncedFilters.showArchived,
], ],
queryFn: async () => { queryFn: async () => {
try { const params = new URLSearchParams();
const params = new URLSearchParams(); if (debouncedFilters.keyword.trim())
if (debouncedFilters.keyword.trim()) params.set('keyword', debouncedFilters.keyword.trim());
params.set('keyword', debouncedFilters.keyword.trim()); if (debouncedFilters.examType) params.set('examType', debouncedFilters.examType);
if (debouncedFilters.examType) params.set('examType', debouncedFilters.examType); if (debouncedFilters.classId) params.set('classId', String(debouncedFilters.classId));
if (debouncedFilters.classId) params.set('classId', String(debouncedFilters.classId)); params.set('isArchived', String(debouncedFilters.showArchived));
params.set('isArchived', String(debouncedFilters.showArchived)); return (
return ( validateResponse<ExamItem[]>(
validateResponse<ExamItem[]>( examsSchema,
examsSchema, await api.get<ExamItem[]>(`/exams?${params.toString()}`),
await api.get<ExamItem[]>(`/exams?${params.toString()}`), ) ?? []
) ?? [] );
);
} catch (error) {
message.error(getErrorMessage(error, '加载考试失败'));
return [];
}
}, },
}); });
const loading = isFetching; const loading = isFetching;
// RouteKeeper 保活页面切回时刷新考试列表
useVisibleRefetch(['exams']);
const saveMutation = useApiMutation( const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) => api.post('/exams', payload), async (payload: Record<string, unknown>) => api.post('/exams', payload),
@@ -343,7 +342,13 @@ const ExamsPage: React.FC = () => {
</Space> </Space>
</div> </div>
{data.length === 0 && !loading ? ( {isError ? (
<QueryErrorState
title="考试数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : data.length === 0 && !loading ? (
<div className="exam-empty"> <div className="exam-empty">
<Empty description="暂无考试" /> <Empty description="暂无考试" />
</div> </div>

View File

@@ -18,6 +18,8 @@ import {
import { archiveViewPolicy, expenseStatusForView } from '../archive-view'; import { archiveViewPolicy, expenseStatusForView } from '../archive-view';
import { ExpenseTablePanel } from './ExpenseTablePanel'; import { ExpenseTablePanel } from './ExpenseTablePanel';
import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals'; import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals';
import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
const ExpensesPage: React.FC = () => { const ExpensesPage: React.FC = () => {
const { modal } = App.useApp(); const { modal } = App.useApp();
@@ -64,6 +66,8 @@ const ExpensesPage: React.FC = () => {
data: expenseResult = { rooms: [], personal: [], students: [], roomsList: [] }, data: expenseResult = { rooms: [], personal: [], students: [], roomsList: [] },
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<{ } = useQuery<{
rooms: any[]; rooms: any[];
personal: any[]; personal: any[];
@@ -72,27 +76,22 @@ const ExpensesPage: React.FC = () => {
}>({ }>({
queryKey: ['expenses', showArchived ? 'archived' : 'active'], queryKey: ['expenses', showArchived ? 'archived' : 'active'],
queryFn: async () => { queryFn: async () => {
try { const [rooms, personal, students, roomsList] = await Promise.all([
const [rooms, personal, students, roomsList] = await Promise.all([ api.get('/expenses/room', {
api.get('/expenses/room', { params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, }),
}), api.get('/expenses/personal', {
api.get('/expenses/personal', { params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, }),
}), api.get('/expenses/student-lookups'),
api.get('/expenses/student-lookups'), api.get('/rooms'),
api.get('/rooms'), ]);
]); return {
return { rooms: validateResponse(expenseRecordsSchema, rooms),
rooms: validateResponse(expenseRecordsSchema, rooms), personal: validateResponse(expenseRecordsSchema, personal),
personal: validateResponse(expenseRecordsSchema, personal), students: validateResponse(expenseStudentLookupsSchema, students),
students: validateResponse(expenseStudentLookupsSchema, students), roomsList: validateResponse(expenseRoomsListSchema, roomsList),
roomsList: validateResponse(expenseRoomsListSchema, roomsList), };
};
} catch {
message.error('加载费用数据失败');
return { rooms: [], personal: [], students: [], roomsList: [] };
}
}, },
}); });
const roomExpenses = expenseResult.rooms; const roomExpenses = expenseResult.rooms;
@@ -100,6 +99,8 @@ const ExpensesPage: React.FC = () => {
const students = expenseResult.students; const students = expenseResult.students;
const rooms = expenseResult.roomsList; const rooms = expenseResult.roomsList;
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新费用列表
useVisibleRefetch(['expenses']);
const mutations = { const mutations = {
saveRoom: useApiMutation( saveRoom: useApiMutation(
@@ -456,103 +457,111 @@ const ExpensesPage: React.FC = () => {
</Button> </Button>
</Space> </Space>
<Tabs {isError ? (
items={[ <QueryErrorState
{ title="费用数据加载失败"
key: 'room', description="请检查网络后重试。"
label: '宿舍费用', onRetry={() => void refetch()}
children: ( />
<ExpenseTablePanel ) : (
kind="room" <Tabs
searchText={roomSearch} items={[
onSearchChange={setRoomSearch} {
typeFilter={roomTypeFilter} key: 'room',
onTypeFilterChange={setRoomTypeFilter} label: '宿舍费用',
typeOptions={typeOptions} children: (
typeMap={typeMap} <ExpenseTablePanel
data={filteredRoomExpenses} kind="room"
loading={loading} searchText={roomSearch}
selectedKeys={selectedRoomKeys} onSearchChange={setRoomSearch}
onSelect={setSelectedRoomKeys} typeFilter={roomTypeFilter}
rooms={rooms} onTypeFilterChange={setRoomTypeFilter}
students={students} typeOptions={typeOptions}
readonly={expenseViewPolicy.readonly} typeMap={typeMap}
showArchived={showArchived} data={filteredRoomExpenses}
canPurgeExpense={canPurgeExpense} loading={loading}
batchLoading={batchLoading} selectedKeys={selectedRoomKeys}
canImport={hasPermission('expense:create')} onSelect={setSelectedRoomKeys}
onBatchRestore={handleBatchRestoreRoom} rooms={rooms}
onBatchPurge={handleBatchPurgeRoom} students={students}
onBatchDelete={handleBatchDeleteRoom} readonly={expenseViewPolicy.readonly}
onSaveCell={saveRoomCell} showArchived={showArchived}
onPeriodSave={async (id, periodStart, periodEnd) => { canPurgeExpense={canPurgeExpense}
try { batchLoading={batchLoading}
await mutations.period.mutateAsync({ id, periodStart, periodEnd }); canImport={hasPermission('expense:create')}
message.success('已保存'); onBatchRestore={handleBatchRestoreRoom}
} catch { onBatchPurge={handleBatchPurgeRoom}
// 错误提示由 useApiMutation 统一处理 onBatchDelete={handleBatchDeleteRoom}
} onSaveCell={saveRoomCell}
}} onPeriodSave={async (id, periodStart, periodEnd) => {
onEdit={openEditRoom} try {
onArchive={(id) => mutations.archiveRoom.mutateAsync(id)} await mutations.period.mutateAsync({ id, periodStart, periodEnd });
onPurge={handlePurgeRoom} message.success('已保存');
onImport={(formData) => mutations.importUtility.mutateAsync(formData)} } catch {
onTemplateDownload={() => { // 错误提示由 useApiMutation 统一处理
void downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch( }
() => message.error('下载失败'), }}
); onEdit={openEditRoom}
}} onArchive={(id) => mutations.archiveRoom.mutateAsync(id)}
onAddUtility={() => setUtilityModal(true)} onPurge={handlePurgeRoom}
/> onImport={(formData) => mutations.importUtility.mutateAsync(formData)}
), onTemplateDownload={() => {
}, void downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(
{ () => message.error('下载失败'),
key: 'personal', );
label: '个人附加费', }}
children: ( onAddUtility={() => setUtilityModal(true)}
<ExpenseTablePanel />
kind="personal" ),
searchText={personalSearch} },
onSearchChange={setPersonalSearch} {
typeFilter={personalTypeFilter} key: 'personal',
onTypeFilterChange={setPersonalTypeFilter} label: '个人附加费',
typeOptions={personalTypeOptions} children: (
typeMap={typeMap} <ExpenseTablePanel
data={filteredPersonalExpenses} kind="personal"
loading={loading} searchText={personalSearch}
selectedKeys={selectedPersonalKeys} onSearchChange={setPersonalSearch}
onSelect={setSelectedPersonalKeys} typeFilter={personalTypeFilter}
rooms={rooms} onTypeFilterChange={setPersonalTypeFilter}
students={students} typeOptions={personalTypeOptions}
readonly={expenseViewPolicy.readonly} typeMap={typeMap}
showArchived={showArchived} data={filteredPersonalExpenses}
canPurgeExpense={canPurgeExpense} loading={loading}
batchLoading={batchLoading} selectedKeys={selectedPersonalKeys}
canImport={hasPermission('expense:create')} onSelect={setSelectedPersonalKeys}
onBatchRestore={handleBatchRestorePersonal} rooms={rooms}
onBatchPurge={handleBatchPurgePersonal} students={students}
onBatchDelete={handleBatchDeletePersonal} readonly={expenseViewPolicy.readonly}
onSaveCell={savePersonalCell} showArchived={showArchived}
onPeriodSave={async () => undefined} canPurgeExpense={canPurgeExpense}
onEdit={openEditPersonal} batchLoading={batchLoading}
onArchive={(id) => mutations.archivePersonal.mutateAsync(id)} canImport={hasPermission('expense:create')}
onPurge={handlePurgePersonal} onBatchRestore={handleBatchRestorePersonal}
onImport={(formData) => mutations.importPersonal.mutateAsync(formData)} onBatchPurge={handleBatchPurgePersonal}
onTemplateDownload={() => { onBatchDelete={handleBatchDeletePersonal}
void downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch( onSaveCell={savePersonalCell}
() => message.error('下载失败'), onPeriodSave={async () => undefined}
); onEdit={openEditPersonal}
}} onArchive={(id) => mutations.archivePersonal.mutateAsync(id)}
onExport={() => { onPurge={handlePurgePersonal}
downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() => onImport={(formData) => mutations.importPersonal.mutateAsync(formData)}
message.error('导出失败'), onTemplateDownload={() => {
); void downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch(
}} () => message.error('下载失败'),
/> );
), }}
}, onExport={() => {
]} downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() =>
/> message.error('导出失败'),
);
}}
/>
),
},
]}
/>
)}
<RoomExpenseModal <RoomExpenseModal
open={roomModal} open={roomModal}

View File

@@ -1,5 +1,6 @@
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 // aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useState, useMemo, useCallback } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { Alert, App, Form } from 'antd'; import { Alert, App, Form } from 'antd';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
@@ -21,6 +22,9 @@ import { occupanciesSchema } from '../../api/schemas';
import { OccupanciesTableArea } from './OccupanciesTableArea'; import { OccupanciesTableArea } from './OccupanciesTableArea';
import { OccupanciesToolbar } from './OccupanciesToolbar'; import { OccupanciesToolbar } from './OccupanciesToolbar';
import { useOccupancyMutations } from './useOccupancyMutations'; import { useOccupancyMutations } from './useOccupancyMutations';
import { QueryErrorState } from '../../components/QueryState';
import { NextStepHint } from '../../components/NextStepHint';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
interface StudentLookupRow { interface StudentLookupRow {
id: number; id: number;
@@ -44,6 +48,7 @@ interface RoomOverviewRow {
const OccupanciesPage: React.FC = () => { const OccupanciesPage: React.FC = () => {
const { modal } = App.useApp(); const { modal } = App.useApp();
const navigate = useNavigate();
const { hasPermission, permissionsReady } = usePermission(); const { hasPermission, permissionsReady } = usePermission();
const canCheckIn = permissionsReady && hasPermission('occupancy:checkin'); const canCheckIn = permissionsReady && hasPermission('occupancy:checkin');
const canCheckOut = permissionsReady && hasPermission('occupancy:checkout'); const canCheckOut = permissionsReady && hasPermission('occupancy:checkout');
@@ -53,6 +58,8 @@ const OccupanciesPage: React.FC = () => {
const [checkInModal, setCheckInModal] = useState(false); const [checkInModal, setCheckInModal] = useState(false);
const [checkOutModal, setCheckOutModal] = useState<any>(null); const [checkOutModal, setCheckOutModal] = useState<any>(null);
const [transferModal, setTransferModal] = useState<any>(null); const [transferModal, setTransferModal] = useState<any>(null);
// 入住成功后的「下一步」引导提示
const [nextStepHint, setNextStepHint] = useState<'billing' | null>(null);
const [viewMode, setViewMode] = useState<OccupancyView>('active'); const [viewMode, setViewMode] = useState<OccupancyView>('active');
const viewPolicy = occupancyViewPolicy(viewMode); const viewPolicy = occupancyViewPolicy(viewMode);
const [autoDeposit, setAutoDeposit] = useState(true); const [autoDeposit, setAutoDeposit] = useState(true);
@@ -75,46 +82,44 @@ const OccupanciesPage: React.FC = () => {
data: fetchResult = { data: [], students: [], rooms: [] }, data: fetchResult = { data: [], students: [], rooms: [] },
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<{ data: OccupancyRow[]; students: StudentLookupRow[]; rooms: RoomOverviewRow[] }>({ } = useQuery<{ data: OccupancyRow[]; students: StudentLookupRow[]; rooms: RoomOverviewRow[] }>({
queryKey: ['occupancies', viewMode, dateRange], queryKey: ['occupancies', viewMode, dateRange],
queryFn: async () => { queryFn: async () => {
try { const [occRes, stuRes, rmRes] = await Promise.allSettled([
const [occRes, stuRes, rmRes] = await Promise.allSettled([ api.get<OccupancyRow[]>('/occupancies', {
api.get<OccupancyRow[]>('/occupancies', { params: {
params: { ...occupancyParamsForView(viewMode),
...occupancyParamsForView(viewMode), dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'),
dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD'),
dateTo: dateRange?.[1]?.format('YYYY-MM-DD'), },
}, }),
}), api.get<StudentLookupRow[]>('/students/basic-lookups'),
api.get<StudentLookupRow[]>('/students/basic-lookups'), api.get<RoomOverviewRow[]>('/rooms/overview'),
api.get<RoomOverviewRow[]>('/rooms/overview'), ]);
]); const labels = ['入住数据', '学生列表', '房间列表'];
const labels = ['入住数据', '学生列表', '房间列表']; [stuRes, rmRes].forEach((res, i) => {
[occRes, stuRes, rmRes].forEach((res, i) => { if (res.status === 'rejected') {
if (res.status === 'rejected') { message.warning(`${labels[i + 1]}加载失败`);
message.warning(`${labels[i]}加载失败`); }
} });
}); if (occRes.status === 'rejected') {
return { throw occRes.reason;
data:
occRes.status === 'fulfilled'
? validateResponse<OccupancyRow[]>(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: [] };
} }
return {
data: validateResponse<OccupancyRow[]>(occupanciesSchema, occRes.value),
students: stuRes.status === 'fulfilled' ? stuRes.value : [],
rooms: rmRes.status === 'fulfilled' ? rmRes.value : [],
};
}, },
}); });
const data = fetchResult.data; const data = fetchResult.data;
const students = fetchResult.students; const students = fetchResult.students;
const rooms = fetchResult.rooms; const rooms = fetchResult.rooms;
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新入住列表
useVisibleRefetch(['occupancies']);
const { const {
checkInMutation, checkInMutation,
@@ -270,6 +275,8 @@ const OccupanciesPage: React.FC = () => {
message.success('入住登记成功'); message.success('入住登记成功');
setCheckInModal(false); setCheckInModal(false);
checkInForm.resetFields(); checkInForm.resetFields();
// 引导业务闭环的下一步:录费用 → 生成账单
setNextStepHint('billing');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} finally { } finally {
@@ -518,26 +525,42 @@ const OccupanciesPage: React.FC = () => {
}); });
}} }}
/> />
<OccupanciesTableArea {nextStepHint === 'billing' && (
columns={columns} <NextStepHint
data={filteredData} title="入住登记完成"
loading={loading} description="接下来可以为该学生录入公共/个人费用,再生成账单完成计费闭环。"
selectedRowKeys={selectedRowKeys} action={{ label: '去费用管理', onClick: () => navigate('/expenses') }}
rowSelection={rowSelection} onClose={() => setNextStepHint(null)}
batchAction={viewPolicy.batchAction} />
canDelete={canDelete} )}
canPurge={canPurge} {isError ? (
batchLoading={batchLoading} <QueryErrorState
onBatchCheckOut={() => { title="入住数据加载失败"
batchCheckOutForm.resetFields(); description="请检查网络后重试。"
batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() }); onRetry={() => void refetch()}
setBatchCheckOutModal(true); />
}} ) : (
onBatchDelete={handleBatchDelete} <OccupanciesTableArea
onBatchRestore={handleBatchRestore} columns={columns}
onBatchPurge={handleBatchPurge} data={filteredData}
onClearSelection={() => setSelectedRowKeys([])} loading={loading}
/> selectedRowKeys={selectedRowKeys}
rowSelection={rowSelection}
batchAction={viewPolicy.batchAction}
canDelete={canDelete}
canPurge={canPurge}
batchLoading={batchLoading}
onBatchCheckOut={() => {
batchCheckOutForm.resetFields();
batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() });
setBatchCheckOutModal(true);
}}
onBatchDelete={handleBatchDelete}
onBatchRestore={handleBatchRestore}
onBatchPurge={handleBatchPurge}
onClearSelection={() => setSelectedRowKeys([])}
/>
)}
<CheckInModal <CheckInModal
open={checkInModal} open={checkInModal}

View File

@@ -103,8 +103,8 @@ const RolesPage: React.FC = () => {
); );
const handleSubmit = async () => { const handleSubmit = async () => {
setSaving(true);
const values = await form.validateFields(); const values = await form.validateFields();
setSaving(true);
try { try {
await saveMutation.mutateAsync({ await saveMutation.mutateAsync({
name: values.name, name: values.name,

View File

@@ -98,16 +98,11 @@ const RoomVisualPage: React.FC = () => {
const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day'); const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading, isFetching } = useQuery<any>({ const { data, isLoading, isFetching, isError } = useQuery<any>({
queryKey: ['rooms', 'visual', isHistorical, asOf], queryKey: ['rooms', 'visual', isHistorical, asOf],
queryFn: async () => { queryFn: async () => {
try { const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined;
const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined; return await api.get('/rooms/visual', { params });
return await api.get('/rooms/visual', { params });
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return null;
}
}, },
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
@@ -141,6 +136,23 @@ const RoomVisualPage: React.FC = () => {
} }
}; };
if (isError && !data) {
return (
<Space direction="vertical" align="center" style={{ display: 'flex', padding: '100px 0' }}>
<Alert type="error" showIcon message="宿舍数据加载失败" description="请检查网络后重试。" />
<Button
type="primary"
loading={loading}
onClick={() =>
void queryClient.refetchQueries({ queryKey: ['rooms', 'visual', isHistorical, asOf] })
}
>
</Button>
</Space>
);
}
if (!data) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />; if (!data) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
const rooms = data.rooms.filter((r: any) => { const rooms = data.rooms.filter((r: any) => {

View File

@@ -12,6 +12,7 @@ import {
} from 'antd'; } from 'antd';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { QueryErrorState } from '../../components/QueryState';
import { import {
BED_STATUS_MAP, BED_STATUS_MAP,
BED_STATUS_OPTIONS, BED_STATUS_OPTIONS,
@@ -26,6 +27,10 @@ export interface RoomDrawerProps {
room: any; room: any;
beds: BedItem[]; beds: BedItem[];
lockers: LockerItem[]; lockers: LockerItem[];
bedsError: boolean;
lockersError: boolean;
onRetryBeds: () => void;
onRetryLockers: () => void;
canEditRooms: boolean; canEditRooms: boolean;
remainingBedSlots: number; remainingBedSlots: number;
defaultBatchBedCount: number; defaultBatchBedCount: number;
@@ -47,6 +52,10 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
room, room,
beds, beds,
lockers, lockers,
bedsError,
lockersError,
onRetryBeds,
onRetryLockers,
canEditRooms, canEditRooms,
remainingBedSlots, remainingBedSlots,
defaultBatchBedCount, defaultBatchBedCount,
@@ -145,7 +154,14 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
{ {
key: 'beds', key: 'beds',
label: `床位管理 (${beds.length})`, label: `床位管理 (${beds.length})`,
children: ( children: bedsError ? (
<QueryErrorState
compact
title="床位加载失败"
description="请检查网络后重试。"
onRetry={onRetryBeds}
/>
) : (
<div> <div>
{canEditRooms ? ( {canEditRooms ? (
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}> <div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>
@@ -267,7 +283,14 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
{ {
key: 'lockers', key: 'lockers',
label: `柜子管理 (${lockers.length})`, label: `柜子管理 (${lockers.length})`,
children: ( children: lockersError ? (
<QueryErrorState
compact
title="柜子加载失败"
description="请检查网络后重试。"
onRetry={onRetryLockers}
/>
) : (
<div> <div>
{canEditRooms ? ( {canEditRooms ? (
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}> <div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>

View File

@@ -166,6 +166,10 @@ export const RoomDetailArea: React.FC<{
drawerRoom: any; drawerRoom: any;
beds: BedItem[]; beds: BedItem[];
lockers: LockerItem[]; lockers: LockerItem[];
bedsError: boolean;
lockersError: boolean;
onRetryBeds: () => void;
onRetryLockers: () => void;
canEditRooms: boolean; canEditRooms: boolean;
remainingBedSlots: number; remainingBedSlots: number;
defaultBatchBedCount: number; defaultBatchBedCount: number;
@@ -208,6 +212,10 @@ export const RoomDetailArea: React.FC<{
room={props.drawerRoom} room={props.drawerRoom}
beds={props.beds} beds={props.beds}
lockers={props.lockers} lockers={props.lockers}
bedsError={props.bedsError}
lockersError={props.lockersError}
onRetryBeds={props.onRetryBeds}
onRetryLockers={props.onRetryLockers}
canEditRooms={props.canEditRooms} canEditRooms={props.canEditRooms}
remainingBedSlots={props.remainingBedSlots} remainingBedSlots={props.remainingBedSlots}
defaultBatchBedCount={props.defaultBatchBedCount} defaultBatchBedCount={props.defaultBatchBedCount}

View File

@@ -12,8 +12,10 @@ import api from '../../api';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { selectArchiveRecords } from '../archive-view'; import { selectArchiveRecords } from '../archive-view';
import { getErrorMessage } from '../../utils/error'; import { getErrorMessage } from '../../utils/error';
import { QueryErrorState } from '../../components/QueryState';
import { useRoomColumns, type BedItem, type LockerItem } from './RoomColumns'; import { useRoomColumns, type BedItem, type LockerItem } from './RoomColumns';
import { RoomDetailArea } from './RoomModals'; import { RoomDetailArea } from './RoomModals';
import { RoomsToolbar } from './RoomsToolbar'; import { RoomsToolbar } from './RoomsToolbar';
@@ -51,28 +53,29 @@ const RoomsPage: React.FC = () => {
const [savingBed, setSavingBed] = useState(false); const [savingBed, setSavingBed] = useState(false);
const [savingLocker, setSavingLocker] = useState(false); const [savingLocker, setSavingLocker] = useState(false);
const [batchLoading, setBatchLoading] = useState(false); const [batchLoading, setBatchLoading] = useState(false);
const [bedsError, setBedsError] = useState(false);
const [lockersError, setLockersError] = useState(false);
const { const {
data = [], data = [],
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<any[]>({ } = useQuery<any[]>({
queryKey: ['rooms', 'overview', showArchived], queryKey: ['rooms', 'overview', showArchived],
queryFn: async () => { queryFn: async () => {
try { const params: any = { includeArchived: showArchived ? 'true' : undefined };
const params: any = { includeArchived: showArchived ? 'true' : undefined }; const res: any = await api.get('/rooms/overview', { params });
const res: any = await api.get('/rooms/overview', { params }); return selectArchiveRecords(
return selectArchiveRecords( validateResponse<any[]>(roomsOverviewSchema, res),
validateResponse<any[]>(roomsOverviewSchema, res), showArchived ? 'archived' : 'active',
showArchived ? 'archived' : 'active', );
);
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return [];
}
}, },
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
useVisibleRefetch(['rooms']);
const { const {
saveMutation, saveMutation,
@@ -223,7 +226,9 @@ const RoomsPage: React.FC = () => {
try { try {
const res = await api.get<BedItem[]>(`/rooms/${roomId}/beds`); const res = await api.get<BedItem[]>(`/rooms/${roomId}/beds`);
setBeds(res); setBeds(res);
setBedsError(false);
} catch (e: unknown) { } catch (e: unknown) {
setBedsError(true);
message.error(getErrorMessage(e, '加载床位失败')); message.error(getErrorMessage(e, '加载床位失败'));
} }
}, []); }, []);
@@ -232,7 +237,9 @@ const RoomsPage: React.FC = () => {
try { try {
const res = await api.get<LockerItem[]>(`/rooms/${roomId}/lockers`); const res = await api.get<LockerItem[]>(`/rooms/${roomId}/lockers`);
setLockers(res); setLockers(res);
setLockersError(false);
} catch (e: unknown) { } catch (e: unknown) {
setLockersError(true);
message.error(getErrorMessage(e, '加载柜子失败')); message.error(getErrorMessage(e, '加载柜子失败'));
} }
}, []); }, []);
@@ -466,13 +473,21 @@ const RoomsPage: React.FC = () => {
onExport={handleExport} onExport={handleExport}
/> />
<RoomsTable {isError ? (
columns={columns} <QueryErrorState
data={filteredData} title="宿舍列表加载失败"
loading={loading} description="请检查网络后重试。"
selectedRowKeys={selectedRowKeys} onRetry={() => void refetch()}
onSelect={setSelectedRowKeys} />
/> ) : (
<RoomsTable
columns={columns}
data={filteredData}
loading={loading}
selectedRowKeys={selectedRowKeys}
onSelect={setSelectedRowKeys}
/>
)}
<RoomDetailArea <RoomDetailArea
modalOpen={modalOpen} modalOpen={modalOpen}
@@ -489,6 +504,14 @@ const RoomsPage: React.FC = () => {
drawerRoom={drawerRoom} drawerRoom={drawerRoom}
beds={beds} beds={beds}
lockers={lockers} lockers={lockers}
bedsError={bedsError}
lockersError={lockersError}
onRetryBeds={() => {
if (drawerRoom) void fetchBeds(drawerRoom.id);
}}
onRetryLockers={() => {
if (drawerRoom) void fetchLockers(drawerRoom.id);
}}
canEditRooms={canEditRooms} canEditRooms={canEditRooms}
remainingBedSlots={remainingBedSlots} remainingBedSlots={remainingBedSlots}
defaultBatchBedCount={defaultBatchBedCount} defaultBatchBedCount={defaultBatchBedCount}

View File

@@ -23,6 +23,7 @@ import {
import { CloudSyncOutlined, EditOutlined, PlusOutlined, StopOutlined } from '@ant-design/icons'; import { CloudSyncOutlined, EditOutlined, PlusOutlined, StopOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { QueryErrorState } from '../../components/QueryState';
import { isMaskedSchedule } from './schedule-visibility'; import { isMaskedSchedule } from './schedule-visibility';
import type { ScheduleFormValues } from './schedule-form'; import type { ScheduleFormValues } from './schedule-form';
import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem } from './ScheduleGrids'; import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem } from './ScheduleGrids';
@@ -340,6 +341,7 @@ export interface SyncModalProps {
mappedClasses: number; mappedClasses: number;
totalClasses: number; totalClasses: number;
} | null; } | null;
syncStatusError: boolean;
syncResult: { syncResult: {
scheduleCount: number; scheduleCount: number;
shiftCount: number; shiftCount: number;
@@ -355,6 +357,7 @@ export interface SyncModalProps {
syncDays: number; syncDays: number;
attendanceMachineOnly: boolean; attendanceMachineOnly: boolean;
onClose: () => void; onClose: () => void;
onRetryStatus: () => void;
onSync: () => void; onSync: () => void;
onDateChange: (date: Dayjs) => void; onDateChange: (date: Dayjs) => void;
onDaysChange: (days: number) => void; onDaysChange: (days: number) => void;
@@ -365,11 +368,13 @@ export const SyncModal: React.FC<SyncModalProps> = ({
open, open,
syncing, syncing,
syncStatus, syncStatus,
syncStatusError,
syncResult, syncResult,
syncDateFrom, syncDateFrom,
syncDays, syncDays,
attendanceMachineOnly, attendanceMachineOnly,
onClose, onClose,
onRetryStatus,
onSync, onSync,
onDateChange, onDateChange,
onDaysChange, onDaysChange,
@@ -555,6 +560,13 @@ export const SyncModal: React.FC<SyncModalProps> = ({
/> />
)} )}
</div> </div>
) : syncStatusError ? (
<QueryErrorState
compact
title="查询同步状态失败"
description="请检查网络后重试。"
onRetry={onRetryStatus}
/>
) : ( ) : (
<Spin description="查询同步状态..." /> <Spin description="查询同步状态..." />
)} )}

View File

@@ -5,7 +5,9 @@ import { CalendarOutlined, CloudSyncOutlined, LeftOutlined, RightOutlined } from
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { QueryErrorState } from '../../components/QueryState';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
@@ -64,6 +66,7 @@ const SchedulesPage: React.FC = () => {
mappedClasses: number; mappedClasses: number;
totalClasses: number; totalClasses: number;
} | null>(null); } | null>(null);
const [syncStatusError, setSyncStatusError] = useState(false);
const [syncResult, setSyncResult] = useState<ScheduleSyncResult | null>(null); const [syncResult, setSyncResult] = useState<ScheduleSyncResult | null>(null);
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs); const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
const [syncDays, setSyncDays] = useState(30); const [syncDays, setSyncDays] = useState(30);
@@ -73,6 +76,7 @@ const SchedulesPage: React.FC = () => {
const openSyncModal = async () => { const openSyncModal = async () => {
setSyncModalOpen(true); setSyncModalOpen(true);
setSyncResult(null); setSyncResult(null);
setSyncStatusError(false);
try { try {
const res = await api.get<{ const res = await api.get<{
success: boolean; success: boolean;
@@ -80,6 +84,7 @@ const SchedulesPage: React.FC = () => {
}>('/sync/schedule/status'); }>('/sync/schedule/status');
setSyncStatus(res.data); setSyncStatus(res.data);
} catch { } catch {
setSyncStatusError(true);
setSyncStatus(null); setSyncStatus(null);
} }
}; };
@@ -154,6 +159,8 @@ const SchedulesPage: React.FC = () => {
data: fetchResult = { classrooms: [], classes: [], matrix: {} }, data: fetchResult = { classrooms: [], classes: [], matrix: {} },
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<{ } = useQuery<{
classrooms: ClassroomItem[]; classrooms: ClassroomItem[];
classes: ClassItem[]; classes: ClassItem[];
@@ -161,51 +168,48 @@ const SchedulesPage: React.FC = () => {
}>({ }>({
queryKey: ['class-schedules', startDateStr, endDateStr, filterClassroomIds], queryKey: ['class-schedules', startDateStr, endDateStr, filterClassroomIds],
queryFn: async () => { queryFn: async () => {
try { const [lookups, schedulesRes] = await Promise.all([
const [lookups, schedulesRes] = await Promise.all([ api.get('/class-schedules/lookups') as Promise<{
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<Record<string, Record<string, ClassScheduleItem[]>>>,
]);
const validatedLookups = validateResponse<{
classrooms: ClassroomItem[]; classrooms: ClassroomItem[];
classes: ClassItem[]; classes: ClassItem[];
}>(scheduleLookupsSchema, lookups); }>,
const validatedWeekly = validateResponse< api.get('/class-schedules/weekly', {
Record<string, Record<string, ClassScheduleItem[]>> params: {
>(weeklyScheduleSchema, schedulesRes); startDate: startDateStr,
endDate: endDateStr,
...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}),
},
}) as Promise<Record<string, Record<string, ClassScheduleItem[]>>>,
]);
const validatedLookups = validateResponse<{
classrooms: ClassroomItem[];
classes: ClassItem[];
}>(scheduleLookupsSchema, lookups);
const validatedWeekly = validateResponse<
Record<string, Record<string, ClassScheduleItem[]>>
>(weeklyScheduleSchema, schedulesRes);
const typedMatrix: Record<number, Record<number, ClassScheduleItem[]>> = {}; const typedMatrix: Record<number, Record<number, ClassScheduleItem[]>> = {};
for (const [cId, dayMap] of Object.entries(validatedWeekly)) { for (const [cId, dayMap] of Object.entries(validatedWeekly)) {
const classroomId = Number(cId); const classroomId = Number(cId);
typedMatrix[classroomId] = {}; typedMatrix[classroomId] = {};
for (const [wd, schedules] of Object.entries(dayMap)) { for (const [wd, schedules] of Object.entries(dayMap)) {
typedMatrix[classroomId][Number(wd)] = schedules; 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 classrooms = fetchResult.classrooms;
const classes = fetchResult.classes; const classes = fetchResult.classes;
const matrix = fetchResult.matrix; const matrix = fetchResult.matrix;
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新排课数据
useVisibleRefetch(['class-schedules']);
const saveMutation = useApiMutation( const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) => async (payload: Record<string, unknown>) =>
@@ -498,18 +502,26 @@ const SchedulesPage: React.FC = () => {
</Space> </Space>
</Card> </Card>
<ScheduleGrid {isError ? (
loading={loading} <QueryErrorState
viewMode={viewMode} title="排课数据加载失败"
classrooms={classrooms} description="请检查网络后重试。"
filteredClassrooms={filteredClassrooms} onRetry={() => void refetch()}
displayMatrix={displayMatrix} />
weeks={weeks} ) : (
monthStart={monthStart} <ScheduleGrid
monthScheduleMap={monthScheduleMap} loading={loading}
onCellClick={handleCellClick} viewMode={viewMode}
onDateClick={handleDateClick} classrooms={classrooms}
/> filteredClassrooms={filteredClassrooms}
displayMatrix={displayMatrix}
weeks={weeks}
monthStart={monthStart}
monthScheduleMap={monthScheduleMap}
onCellClick={handleCellClick}
onDateClick={handleDateClick}
/>
)}
<ScheduleModal <ScheduleModal
open={modalOpen} open={modalOpen}
@@ -558,6 +570,7 @@ const SchedulesPage: React.FC = () => {
open={syncModalOpen} open={syncModalOpen}
syncing={syncing} syncing={syncing}
syncStatus={syncStatus} syncStatus={syncStatus}
syncStatusError={syncStatusError}
syncResult={syncResult} syncResult={syncResult}
syncDateFrom={syncDateFrom} syncDateFrom={syncDateFrom}
syncDays={syncDays} syncDays={syncDays}
@@ -566,6 +579,7 @@ const SchedulesPage: React.FC = () => {
setSyncModalOpen(false); setSyncModalOpen(false);
setSyncResult(null); setSyncResult(null);
}} }}
onRetryStatus={() => void openSyncModal()}
onSync={handleSyncSchedule} onSync={handleSyncSchedule}
onDateChange={setSyncDateFrom} onDateChange={setSyncDateFrom}
onDaysChange={setSyncDays} onDaysChange={setSyncDays}

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { import {
App,
Button, Button,
Input, Input,
Popconfirm, Popconfirm,
@@ -94,6 +95,7 @@ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
onDownloadTemplate, onDownloadTemplate,
onExport, onExport,
}) => { }) => {
const { modal } = App.useApp();
return ( return (
<> <>
<div className="responsive-toolbar"> <div className="responsive-toolbar">
@@ -236,7 +238,24 @@ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onCreateImport}> <Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onCreateImport}>
<Button icon={<UploadOutlined />}>Excel</Button> <Button icon={<UploadOutlined />}>Excel</Button>
</Upload> </Upload>
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onUpdateImport}> <Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={(options) => {
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 });
},
});
}}
>
<Button icon={<SwapOutlined />}></Button> <Button icon={<SwapOutlined />}></Button>
</Upload> </Upload>
</> </>

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { import {
App, App,
Form, Form,
@@ -6,6 +7,9 @@ import {
import api from '../../api'; import api from '../../api';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useUserStore } from '../../store/user/userStore'; 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 { selectArchiveRecords } from '../archive-view';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
@@ -59,6 +63,7 @@ interface StudentFilterLookups {
const StudentsPage: React.FC = () => { const StudentsPage: React.FC = () => {
const { modal } = App.useApp(); const { modal } = App.useApp();
const navigate = useNavigate();
const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission(); const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission();
const canViewOrganizations = hasPermission('organization:view'); const canViewOrganizations = hasPermission('organization:view');
const canLoadOrganizations = hasAnyPermission( const canLoadOrganizations = hasAnyPermission(
@@ -92,6 +97,8 @@ const StudentsPage: React.FC = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [jinshujuOpen, setJinshujuOpen] = useState(false); const [jinshujuOpen, setJinshujuOpen] = useState(false);
// 导入成功后的「下一步」引导提示
const [nextStepHint, setNextStepHint] = useState<'class' | null>(null);
const openDrawer = useCallback((studentId: number) => { const openDrawer = useCallback((studentId: number) => {
setDrawerStudentId(studentId); setDrawerStudentId(studentId);
@@ -152,6 +159,8 @@ const StudentsPage: React.FC = () => {
data = [], data = [],
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<any[]>({ } = useQuery<any[]>({
queryKey: [ queryKey: [
'students', 'students',
@@ -163,28 +172,25 @@ const StudentsPage: React.FC = () => {
filterTeacherId, filterTeacherId,
], ],
queryFn: async () => { queryFn: async () => {
try { const params: Record<string, unknown> = {
const params: Record<string, unknown> = { name: searchName || undefined,
name: searchName || undefined, includeArchived: showArchived ? 'true' : undefined,
includeArchived: showArchived ? 'true' : undefined, };
}; if (showArchived) params.status = 'archived';
if (showArchived) params.status = 'archived'; else if (filterStatus) params.status = filterStatus;
else if (filterStatus) params.status = filterStatus; if (effectiveFilterOrganizationId) params.organizationId = effectiveFilterOrganizationId;
if (effectiveFilterOrganizationId) params.organizationId = effectiveFilterOrganizationId; if (filterClassId) params.classId = filterClassId;
if (filterClassId) params.classId = filterClassId; if (filterTeacherId) params.teacherId = filterTeacherId;
if (filterTeacherId) params.teacherId = filterTeacherId; const res = (await api.get('/students', { params })) as Array<Record<string, unknown>>;
const res = (await api.get('/students', { params })) as Array<Record<string, unknown>>; return selectArchiveRecords(
return selectArchiveRecords( validateResponse<Array<Record<string, unknown>>>(studentsSchema, res),
validateResponse<Array<Record<string, unknown>>>(studentsSchema, res), showArchived ? 'archived' : 'active',
showArchived ? 'archived' : 'active', );
);
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return [];
}
}, },
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
useVisibleRefetch(['students']);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidateStudents: Array<readonly unknown[]> = [['students']]; const invalidateStudents: Array<readonly unknown[]> = [['students']];
@@ -428,6 +434,10 @@ const StudentsPage: React.FC = () => {
const res = (await importMutation.mutateAsync(formData)) as StudentCreateImportResult; const res = (await importMutation.mutateAsync(formData)) as StudentCreateImportResult;
showCreateImportResult(modal, res); showCreateImportResult(modal, res);
onSuccess?.(res); onSuccess?.(res);
// 导入成功且产生了新学生时,提示业务闭环的下一步
if (typeof res.imported === 'number' && res.imported > 0) {
setNextStepHint('class');
}
} catch (e) { } catch (e) {
onError?.(e instanceof Error ? e : new Error(getErrorMessage(e, '导入失败'))); onError?.(e instanceof Error ? e : new Error(getErrorMessage(e, '导入失败')));
} }
@@ -497,6 +507,8 @@ const StudentsPage: React.FC = () => {
onViewSensitive: handleViewSensitive, onViewSensitive: handleViewSensitive,
onOpenDrawer: openDrawer, onOpenDrawer: openDrawer,
onEdit: (record) => { onEdit: (record) => {
// 先重置再回填,避免上一名学生的字段残留到当前表单
form.resetFields();
setEditing(record); setEditing(record);
form.setFieldsValue(record); form.setFieldsValue(record);
setModalOpen(true); setModalOpen(true);
@@ -570,16 +582,32 @@ const StudentsPage: React.FC = () => {
onDownloadTemplate={handleDownloadTemplate} onDownloadTemplate={handleDownloadTemplate}
onExport={handleExport} onExport={handleExport}
/> />
<StudentsTable {nextStepHint === 'class' && (
columns={columns} <NextStepHint
data={data} title="学生资料已导入"
loading={loading} description="接下来可以为学生分班、安排排课,形成教学闭环。"
pageInfo={pageInfo} action={{ label: '去班级管理', onClick: () => navigate('/classes') }}
onPageChange={(current, pageSize) => setPageInfo({ current, pageSize })} onClose={() => setNextStepHint(null)}
selectedRowKeys={selectedRowKeys} />
onSelect={setSelectedRowKeys} )}
onClearSelection={() => setSelectedRowKeys([])} {isError ? (
/> <QueryErrorState
title="学生数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<StudentsTable
columns={columns}
data={data}
loading={loading}
pageInfo={pageInfo}
onPageChange={(current, pageSize) => setPageInfo({ current, pageSize })}
selectedRowKeys={selectedRowKeys}
onSelect={setSelectedRowKeys}
onClearSelection={() => setSelectedRowKeys([])}
/>
)}
<StudentEditModal <StudentEditModal
open={modalOpen && canSaveStudent} open={modalOpen && canSaveStudent}
editing={!!editing} editing={!!editing}
@@ -591,6 +619,7 @@ const StudentsPage: React.FC = () => {
onCancel={() => { onCancel={() => {
setModalOpen(false); setModalOpen(false);
setEditing(null); setEditing(null);
form.resetFields();
}} }}
/> />
{canSyncJinshuju ? ( {canSyncJinshuju ? (

View File

@@ -6,7 +6,7 @@ import { teacherWorkspaceSchema } from '../../api/schemas';
import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd'; import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message'; import { QueryErrorState } from '../../components/QueryState';
interface AssignedClass { interface AssignedClass {
classId: number; classId: number;
@@ -59,19 +59,13 @@ const WEEKDAY_LABELS: Record<string, string> = {
}; };
const TeacherWorkspacePage: React.FC = () => { const TeacherWorkspacePage: React.FC = () => {
const { data, isLoading, isFetching } = useQuery<WorkspaceData | null>({ const { data, isLoading, isFetching, isError, refetch } = useQuery<WorkspaceData | null>({
queryKey: ['rbac', 'teacher-workspace'], queryKey: ['rbac', 'teacher-workspace'],
queryFn: async () => { queryFn: async () => {
try { return validateResponse<WorkspaceData>(
return validateResponse<WorkspaceData>( teacherWorkspaceSchema,
teacherWorkspaceSchema, await api.get('/rbac/teacher-workspace'),
await api.get('/rbac/teacher-workspace'), );
);
} catch (e) {
console.error(e);
message.error('数据加载失败');
return null;
}
}, },
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
@@ -136,6 +130,16 @@ const TeacherWorkspacePage: React.FC = () => {
[], [],
); );
if (isError) {
return (
<QueryErrorState
title="工作台数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
);
}
if (loading) { if (loading) {
return ( return (
<div style={{ textAlign: 'center', padding: 80 }}> <div style={{ textAlign: 'center', padding: 80 }}>

View File

@@ -11,6 +11,8 @@ import { message } from '../../ui/app-message';
import EditableCell from '../../components/EditableCell'; import EditableCell from '../../components/EditableCell';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
interface TeacherRow { interface TeacherRow {
id: number; id: number;
@@ -68,24 +70,24 @@ const TeachersPage: React.FC = () => {
data: fetchResult = { list: [], total: 0 }, data: fetchResult = { list: [], total: 0 },
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<TeacherListResponse>({ } = useQuery<TeacherListResponse>({
queryKey: ['rbac', 'teachers', page, pageSize, search], queryKey: ['rbac', 'teachers', page, pageSize, search],
queryFn: async () => { queryFn: async () => {
try { return validateResponse<TeacherListResponse>(
return validateResponse<TeacherListResponse>( teacherListSchema,
teacherListSchema, await api.get<TeacherListResponse>('/rbac/teachers', {
await api.get<TeacherListResponse>('/rbac/teachers', { params: { search: search || undefined, page, pageSize },
params: { search: search || undefined, page, pageSize }, }),
}), );
);
} catch {
return { list: [], total: 0 };
}
}, },
}); });
const data = fetchResult.list; const data = fetchResult.list;
const total = fetchResult.total; const total = fetchResult.total;
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新教师列表
useVisibleRefetch(['rbac', 'teachers']);
const saveProfileMutation = useApiMutation( const saveProfileMutation = useApiMutation(
async ({ async ({
@@ -248,39 +250,47 @@ const TeachersPage: React.FC = () => {
style={{ width: 220 }} style={{ width: 220 }}
/> />
</Space> </Space>
<Table {isError ? (
columns={columns} <QueryErrorState
dataSource={data} title="教师数据加载失败"
rowKey="id" description="请检查网络后重试。"
loading={loading} onRetry={() => void refetch()}
scroll={{ x: 1300 }} />
pagination={{ ) : (
current: page, <Table
pageSize, columns={columns}
total, dataSource={data}
showSizeChanger: true, rowKey="id"
pageSizeOptions: [20, 50, 100], loading={loading}
onChange: (nextPage, nextPageSize) => { scroll={{ x: 1300 }}
setPage(nextPage); pagination={{
setPageSize(nextPageSize); current: page,
}, pageSize,
showTotal: (t) => `${t}`, total,
}} showSizeChanger: true,
expandable={{ pageSizeOptions: [20, 50, 100],
rowExpandable: (r) => (r.classAssignments || []).length > 0, onChange: (nextPage, nextPageSize) => {
expandedRowRender: (r) => setPage(nextPage);
r.classAssignments?.length ? ( setPageSize(nextPageSize);
<Space wrap> },
{r.classAssignments.map((a, i) => ( showTotal: (t) => `${t}`,
<Tag key={i} color="blue"> }}
{ROLE_TYPE_LABELS[a.roleType] || a.roleType}: {a.className} expandable={{
{a.subject ? `${a.subject}` : ''} rowExpandable: (r) => (r.classAssignments || []).length > 0,
</Tag> expandedRowRender: (r) =>
))} r.classAssignments?.length ? (
</Space> <Space wrap>
) : null, {r.classAssignments.map((a, i) => (
}} <Tag key={i} color="blue">
/> {ROLE_TYPE_LABELS[a.roleType] || a.roleType}: {a.className}
{a.subject ? `${a.subject}` : ''}
</Tag>
))}
</Space>
) : null,
}}
/>
)}
<Modal <Modal
title={`编辑档案 — ${profileModal?.name || ''}`} title={`编辑档案 — ${profileModal?.name || ''}`}
open={!!profileModal && canEditTeachers} open={!!profileModal && canEditTeachers}

View File

@@ -150,8 +150,8 @@ const UsersPage: React.FC = () => {
); );
const handleSubmit = async () => { const handleSubmit = async () => {
setSaving(true);
const values = await form.validateFields(); const values = await form.validateFields();
setSaving(true);
try { try {
await saveMutation.mutateAsync({ await saveMutation.mutateAsync({
username: values.username, username: values.username,
@@ -211,8 +211,8 @@ const UsersPage: React.FC = () => {
); );
const handlePwdSubmit = async () => { const handlePwdSubmit = async () => {
setSaving(true);
const values = await pwdForm.validateFields(); const values = await pwdForm.validateFields();
setSaving(true);
try { try {
await pwdMutation.mutateAsync({ id: resetTarget.id, password: values.password }); await pwdMutation.mutateAsync({ id: resetTarget.id, password: values.password });
message.success('密码已重置'); message.success('密码已重置');

View File

@@ -23,6 +23,8 @@ import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { newOperationId } from '../../utils/operation-id'; import { newOperationId } from '../../utils/operation-id';
import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
interface WalletRow { interface WalletRow {
studentId: number; studentId: number;
@@ -54,7 +56,6 @@ const WalletsPage: React.FC = () => {
const [debtOnly, setDebtOnly] = useState(false); const [debtOnly, setDebtOnly] = useState(false);
const [roomType, setRoomType] = useState<string | undefined>(); const [roomType, setRoomType] = useState<string | undefined>();
const [selected, setSelected] = useState<WalletRow | null>(null); const [selected, setSelected] = useState<WalletRow | null>(null);
const [transactions, setTransactions] = useState<WalletTransaction[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [batchForm] = Form.useForm(); const [batchForm] = Form.useForm();
@@ -66,21 +67,17 @@ const WalletsPage: React.FC = () => {
data: rows = [], data: rows = [],
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch, refetch,
} = useQuery<WalletRow[]>({ } = useQuery<WalletRow[]>({
queryKey: ['wallets', keyword, debtOnly, roomType], queryKey: ['wallets', keyword, debtOnly, roomType],
queryFn: async () => { queryFn: async () => {
try { return validateResponse<WalletRow[]>(
return validateResponse<WalletRow[]>( walletsSchema,
walletsSchema, await api.get('/wallets', {
await api.get('/wallets', { params: { keyword: keyword || undefined, debtOnly, roomType },
params: { keyword: keyword || undefined, debtOnly, roomType }, }),
}), );
);
} catch (error: any) {
message.error(error?.message || '加载学生余额失败');
return [];
}
}, },
}); });
const { data: roomTypes = [] } = useQuery<string[]>({ const { data: roomTypes = [] } = useQuery<string[]>({
@@ -98,6 +95,27 @@ const WalletsPage: React.FC = () => {
}, },
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新余额,避免费用/账单操作后数据陈旧
useVisibleRefetch(['wallets']);
const selectedStudentId = selected?.studentId;
const {
data: transactions = [],
isLoading: txLoading,
isFetching: txFetching,
isError: txError,
refetch: refetchTransactions,
} = useQuery<WalletTransaction[]>({
queryKey: ['wallets', 'transactions', selectedStudentId],
enabled: drawerOpen && !!selectedStudentId,
queryFn: async () => {
return (
(await api.get<WalletTransaction[]>('/wallets/transactions', {
params: { studentId: selectedStudentId },
})) || []
);
},
});
const fetchRows = useCallback(() => refetch(), [refetch]); const fetchRows = useCallback(() => refetch(), [refetch]);
const changeMutation = useApiMutation( const changeMutation = useApiMutation(
@@ -193,21 +211,11 @@ const WalletsPage: React.FC = () => {
}; };
const showTransactions = useCallback( const showTransactions = useCallback(
async (row: WalletRow) => { (row: WalletRow) => {
setSelected(row); setSelected(row);
setDrawerOpen(true); setDrawerOpen(true);
try {
setTransactions(
await api.get<WalletTransaction[]>('/wallets/transactions', {
params: { studentId: row.studentId },
}),
);
} catch (error: unknown) {
console.error('加载余额流水失败', error);
message.error('加载流水失败');
}
}, },
[setSelected, setDrawerOpen, setTransactions], [setSelected, setDrawerOpen],
); );
const columns = useMemo( const columns = useMemo(
@@ -310,19 +318,27 @@ const WalletsPage: React.FC = () => {
</Button> </Button>
</Space> </Space>
</div> </div>
<Table {isError ? (
rowKey="studentId" <QueryErrorState
loading={loading} title="钱包数据加载失败"
dataSource={rows} description="请检查网络后重试。"
columns={columns} onRetry={() => void refetch()}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} />
pagination={{ ) : (
defaultPageSize: 15, <Table
showSizeChanger: true, rowKey="studentId"
pageSizeOptions: [15, 30, 50, 100], loading={loading}
showTotal: (total) => `${total}`, dataSource={rows}
}} columns={columns}
/> rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
/>
)}
<Modal <Modal
title={`${selected?.studentName || ''} - 余额操作`} title={`${selected?.studentName || ''} - 余额操作`}
open={!!selected && !drawerOpen} open={!!selected && !drawerOpen}
@@ -400,43 +416,53 @@ const WalletsPage: React.FC = () => {
setSelected(null); setSelected(null);
}} }}
> >
<Table {txError ? (
rowKey="id" <QueryErrorState
dataSource={transactions} compact
pagination={{ pageSize: 10 }} title="流水加载失败"
columns={[ description="请检查网络后重试。"
{ onRetry={() => void refetchTransactions()}
title: '时间', />
dataIndex: 'createdAt', ) : (
render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm'), <Table
}, rowKey="id"
{ dataSource={transactions}
title: '类型', loading={txLoading || txFetching}
dataIndex: 'type', pagination={{ pageSize: 10 }}
render: (value: string) => transactionNames[value] || value, columns={[
}, {
{ title: '时间',
title: '金额', dataIndex: 'createdAt',
dataIndex: 'amount', render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm'),
render: (value: number) => ( },
<span style={{ color: value >= 0 ? '#389e0d' : '#cf1322' }}> {
{value >= 0 ? '+' : ''}¥{value.toFixed(2)} title: '类型',
</span> dataIndex: 'type',
), render: (value: string) => transactionNames[value] || value,
}, },
{ {
title: '变动后余额', title: '额',
dataIndex: 'balanceAfter', dataIndex: 'amount',
render: (value: number) => `¥${value.toFixed(2)}`, render: (value: number) => (
}, <span style={{ color: value >= 0 ? '#389e0d' : '#cf1322' }}>
{ {value >= 0 ? '+' : ''}¥{value.toFixed(2)}
title: '关联账单', </span>
dataIndex: 'billId', ),
render: (value: number) => (value ? `#${value}` : '-'), },
}, {
{ title: '说明', dataIndex: 'description' }, title: '变动后余额',
]} dataIndex: 'balanceAfter',
/> render: (value: number) => `¥${value.toFixed(2)}`,
},
{
title: '关联账单',
dataIndex: 'billId',
render: (value: number) => (value ? `#${value}` : '-'),
},
{ title: '说明', dataIndex: 'description' },
]}
/>
)}
</Drawer> </Drawer>
</div> </div>
); );

View File

@@ -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`
仍可渲染——建议先迁移历史数据或保留兼容读取。