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 && !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">
{selectionMode ? (
<>

View File

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

View File

@@ -16,6 +16,7 @@ import { useUserStore } from '../../store/user/userStore';
import { DynamicChart } from './DynamicChart';
import { DynamicForm } from './DynamicForm';
import { DynamicReview } from './DynamicReview';
import { ArtifactErrorBoundary } from './ArtifactErrorBoundary';
import { LiteCodeHighlighter } from './LiteCodeHighlighter';
import { LiteMermaid } from './LiteMermaid';
import type {
@@ -355,26 +356,30 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
/>
)}
{(message.forms ?? []).map((form) => (
<DynamicForm
key={form.id}
form={form}
disabled={streaming}
onSubmit={(values) => onSubmitForm?.(form, values)}
/>
<ArtifactErrorBoundary key={form.id} title="表单">
<DynamicForm
form={form}
disabled={streaming}
onSubmit={(values) => onSubmitForm?.(form, values)}
/>
</ArtifactErrorBoundary>
))}
{(message.reviews ?? []).map((review: AiReviewSchema) => (
<DynamicReview
key={review.id}
review={review}
messageId={typeof message.id === 'number' ? message.id : undefined}
disabled={streaming}
onSubmit={(reviewId) => onSubmitReview?.(reviewId, review.title)}
onConfirmStep={onConfirmReviewStep}
onConfirmGroup={onConfirmReviewGroup}
/>
<ArtifactErrorBoundary key={review.id} title="导入预览">
<DynamicReview
review={review}
messageId={typeof message.id === 'number' ? message.id : undefined}
disabled={streaming}
onSubmit={(reviewId) => onSubmitReview?.(reviewId, review.title)}
onConfirmStep={onConfirmReviewStep}
onConfirmGroup={onConfirmReviewGroup}
/>
</ArtifactErrorBoundary>
))}
{(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.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 { Button, Spin, Tag, Tooltip, Typography } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
import type { EChartsType } from 'echarts/core';
import type { EChartsOption } from '../../components/ECharts';
import type { AiChartSchema } from './types';
import { useXCardSurface } from './useSubmissionState';
// echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取
const ReactECharts = lazy(() => import('../../components/ECharts'));
@@ -202,6 +203,27 @@ const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]);
const [instance, setInstance] = useState<EChartsType | null>(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 = () => {
if (!instance) return;
@@ -254,46 +276,39 @@ export interface DynamicChartProps {
* so history replays identically.
*/
export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => {
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
const idRef = useRef<string>('');
const sid = surfaceId(chart.id);
const { commands, pushCommands } = useXCardSurface(sid);
useEffect(() => {
const sid = surfaceId(chart.id);
if (idRef.current !== sid) {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
const cmds: XAgentCommand_v0_9[] = [
{
version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID },
});
}
cmds.push({
version: 'v0.9',
updateDataModel: {
surfaceId: sid,
path: '/chart',
value: chart,
},
});
cmds.push({
version: 'v0.9',
updateComponents: {
surfaceId: sid,
components: [
{
id: 'root',
component: 'ChartPreview',
chart: { path: '/chart' },
},
],
{
version: 'v0.9',
updateDataModel: {
surfaceId: sid,
path: '/chart',
value: chart,
},
},
});
setCommands([...cmds]);
}, [chart]);
{
version: 'v0.9',
updateComponents: {
surfaceId: sid,
components: [
{
id: 'root',
component: 'ChartPreview',
chart: { path: '/chart' },
},
],
},
},
];
pushCommands(cmds);
}, [chart, pushCommands, sid]);
return (
<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 {
XCard,
registerCatalog,
@@ -18,6 +18,7 @@ import {
} from 'antd';
import dayjs from 'dayjs';
import type { AiFormField, AiFormSchema } from './types';
import { useSubmissionState, useXCardSurface } from './useSubmissionState';
const FORM_CATALOG_ID = 'gongxue-form-catalog';
@@ -179,63 +180,46 @@ export interface DynamicFormProps {
* success/failure/loading transitions are pushed as incremental commands.
*/
export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => {
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
const idRef = useRef<string>('');
const sid = surfaceId(form.id);
const { submitting, submitted, error, run } = useSubmissionState();
const { commands, pushCommands } = useXCardSurface(sid);
useEffect(() => {
const sid = surfaceId(form.id);
if (idRef.current !== sid) {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
const cmds: XAgentCommand_v0_9[] = [
{
version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID },
});
}
cmds.push({
version: 'v0.9',
updateDataModel: {
surfaceId: sid,
path: '/form',
value: { ...form, submitting, submitted, error },
},
});
cmds.push({
version: 'v0.9',
updateComponents: {
surfaceId: sid,
components: [
{
id: 'root',
component: 'FormPreview',
form: { path: '/form' },
disabled: Boolean(disabled),
},
],
{
version: 'v0.9',
updateDataModel: {
surfaceId: sid,
path: '/form',
value: { ...form, submitting, submitted, error },
},
},
});
setCommands([...cmds]);
}, [disabled, error, form, submitted, submitting]);
{
version: 'v0.9',
updateComponents: {
surfaceId: sid,
components: [
{
id: 'root',
component: 'FormPreview',
form: { path: '/form' },
disabled: Boolean(disabled),
},
],
},
},
];
pushCommands(cmds);
}, [disabled, error, form, pushCommands, sid, submitted, submitting]);
const handleSubmit = async (values: Record<string, unknown>) => {
if (submitting) return;
setSubmitting(true);
setError(null);
try {
const handleSubmit = (values: Record<string, unknown>) => {
void run(async () => {
await onSubmit(values);
setSubmitted(true);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
});
};
const handleAction = (payload: ActionPayload) => {

View File

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

View File

@@ -138,6 +138,16 @@
inset: 68px 0 auto;
}
.ai-chat-sidebar__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 24px 16px;
}
.ai-chat-sidebar__footer {
flex: none;
display: flex;

View File

@@ -327,8 +327,9 @@ export function useAiChatMessageActions({
);
const submitForm = useCallback(
(form: AiFormSchema, values: Record<string, unknown>) => {
if (!activeId || isRequesting) return;
async (form: AiFormSchema, values: Record<string, unknown>): Promise<void> => {
if (!activeId) throw new Error('当前会话不可用,请稍后重试');
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再提交表单');
requestWithStatus({
message: '表单提交',
attachmentIds: [],
@@ -342,8 +343,9 @@ export function useAiChatMessageActions({
);
const submitReview = useCallback(
(reviewId: string, reviewTitle?: string) => {
if (!activeId || isRequesting) return;
async (reviewId: string, reviewTitle?: string): Promise<void> => {
if (!activeId) throw new Error('当前会话不可用,请稍后重试');
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再确认导入');
requestWithStatus({
message: '确认批量导入',
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 { useImmer } from 'use-immer';
import { Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd';
import { App, Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd';
import { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
import api from '../api';
import { message } from '../ui/app-message';
@@ -30,6 +30,7 @@ interface MatchModalProps {
}
const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => {
const { modal } = App.useApp();
const { hasPermission, hasAllPermissions, permissionsReady } = usePermission();
const canTriggerSync = hasPermission('sync:trigger');
const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger');
@@ -168,6 +169,22 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
};
const handleClose = () => {
// 已进入匹配步骤且存在待处理数据时,关闭会丢失全部决策,需先确认
const hasPendingWork = step === 'match' && (entries.length > 0 || decisions.size > 0);
if (hasPendingWork) {
modal.confirm({
title: '放弃当前匹配?',
content: '已确认的匹配决策将全部丢失,且不会写入系统。',
okText: '放弃',
okButtonProps: { danger: true },
cancelText: '继续匹配',
onOk: () => {
reset();
onClose();
},
});
return;
}
reset();
onClose();
};
@@ -330,6 +347,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
onCancel={handleClose}
width={step === 'match' || step === 'applying' ? 900 : 640}
mask={{ closable: false }}
closable={step !== 'applying'}
footer={
step === 'connection'
? [
@@ -380,7 +398,12 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
{step === 'rule' ? renderRuleStep() : null}
{step === 'match' ? renderMatchStep() : null}
{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}
</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 { useLocation, useOutlet } from 'react-router';
import AppErrorBoundary from './AppErrorBoundary';
import { ActivePageContext } from './routeKeeperContext';
const MAX_CACHED_PAGES = 30;
/**
* 路由保活:切换页面时保留已访问页面的组件实例(输入、滚动、弹窗状态不丢失)。
* 隐藏页面仍挂载在 DOM 中,仅通过 display:none 隐藏。
*
* - 每个缓存页外层包裹 AppErrorBoundary单页渲染异常不影响其他缓存页。
* - 通过 ActivePageContext 向页面暴露「当前激活页路径」,供 usePageVisible 使用。
*/
export const RouteKeeper: React.FC = () => {
const location = useLocation();
@@ -26,17 +31,17 @@ export const RouteKeeper: React.FC = () => {
}
return (
<>
<ActivePageContext.Provider value={pageKey}>
{Array.from(cacheRef.current.entries()).map(([key, node]) => (
<div
key={key}
className="route-keeper-page"
style={{ display: key === pageKey ? undefined : 'none' }}
>
{node}
<AppErrorBoundary>{node}</AppErrorBoundary>
</div>
))}
</>
</ActivePageContext.Provider>
);
};

View File

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

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';
import AppErrorBoundary from './components/AppErrorBoundary';
import './index.css';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
@@ -42,9 +43,11 @@ if (!rootElement) throw new Error('未找到 #root 挂载点');
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
<AppErrorBoundary>
<QueryClientProvider client={queryClient}>
<App />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</AppErrorBoundary>
</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 dayjs from 'dayjs';
import api from '../../api';
import { message } from '../../ui/app-message';
import { QueryErrorState } from '../../components/QueryState';
import {
filterLessonAttendanceRecords,
getPunchDisplayInfo,
@@ -84,37 +85,44 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
const [session, setSession] = useState<LessonAttendanceSession | null>(null);
const [records, setRecords] = useState<LessonAttendanceRecord[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [keyword, setKeyword] = useState('');
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(() => {
if (!schedule) return;
let cancelled = false;
cancelledRef.current = false;
setLoadedSchedule(schedule);
setLoading(true);
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);
});
void loadLesson();
return () => {
cancelled = true;
cancelledRef.current = true;
};
}, [schedule]);
}, [schedule, loadLesson]);
const updateRecord = useCallback(async (record: LessonAttendanceRecord, status: string) => {
const previous = record.status;
@@ -185,19 +193,26 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
{filteredRecords.length} / {records.length}
</span>
</div>
<Table<LessonAttendanceRecord>
rowKey="id"
loading={loading}
dataSource={filteredRecords}
pagination={false}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={records.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
/>
),
}}
{error ? (
<QueryErrorState
title="本节课考勤加载失败"
description={error}
onRetry={() => void loadLesson()}
/>
) : (
<Table<LessonAttendanceRecord>
rowKey="id"
loading={loading}
dataSource={filteredRecords}
pagination={false}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={records.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
/>
),
}}
columns={[
{
title: '学生',
@@ -264,7 +279,8 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
render: (value: string | null) => value || <span className="muted-text"></span>,
},
]}
/>
/>
)}
</Drawer>
);
};

View File

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

View File

@@ -11,8 +11,7 @@ import {
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { message } from '../../ui/app-message';
import { getErrorMessage } from '../../utils/error';
import { QueryErrorState } from '../../components/QueryState';
import {
canPullAttendance,
getSchedulePhase,
@@ -41,21 +40,18 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c
const {
data: workspace,
isLoading,
isFetching,
isPending,
isError,
refetch,
} = useQuery<TeacherWorkspaceData | null>({
queryKey: ['attendance', 'workspace'],
queryFn: async () => {
try {
return await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace');
} catch (error: unknown) {
message.error(getErrorMessage(error, '加载今日课程失败'));
return null;
}
return await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace');
},
});
const loading = isLoading || isFetching;
// isPending 覆盖自动重试的退避窗口,避免「加载失败/重试中」短暂闪现为空态
const loading = isPending || isFetching;
const loadWorkspace = useCallback(() => refetch(), [refetch]);
const classNameById = useMemo(
@@ -124,7 +120,15 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c
</div>
<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">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}

View File

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

View File

@@ -24,6 +24,9 @@ import {
import dayjs from 'dayjs';
import api from '../../api';
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 { message } from '../../ui/app-message';
import { buildBillPrintHtml, type BillPrintData } from './bill-print';
@@ -64,26 +67,27 @@ const BillsPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
// 生成账单成功后的「下一步」引导提示
const [billGeneratedHint, setBillGeneratedHint] = useState(false);
const {
data: bills = [],
isLoading,
isFetching,
isError,
refetch,
} = useQuery({
queryKey: ['bills', filterStatus, filterExpenseType],
queryFn: async () => {
try {
const params: Record<string, string | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterExpenseType) params.expenseType = filterExpenseType;
return validateResponse<unknown[]>(billsSchema, await api.get('/bills', { params }));
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
const params: Record<string, string | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterExpenseType) params.expenseType = filterExpenseType;
return validateResponse<unknown[]>(billsSchema, await api.get('/bills', { params }));
},
});
const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新账单列表
useVisibleRefetch(['bills']);
const generateMutation = useApiMutation(
async (payload: { operationId: string; billingMonth: string }) =>
@@ -122,8 +126,8 @@ const BillsPage: React.FC = () => {
}, [bills, searchText, filterStatus]);
const handleGenerate = async () => {
setSaving(true);
const values = await generateForm.validateFields();
setSaving(true);
try {
const res: any = await generateMutation.mutateAsync({
operationId: newOperationId(),
@@ -132,6 +136,7 @@ const BillsPage: React.FC = () => {
message.success(res.message || '生成成功');
setGenerateModal(false);
generateForm.resetFields();
setBillGeneratedHint(true);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
@@ -470,19 +475,42 @@ const BillsPage: React.FC = () => {
</PermissionButton>
</Space>
</div>
<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[]),
}}
/>
{billGeneratedHint && (
<NextStepHint
title="账单已生成"
description="请核对账单明细,确认后标记已付,完成「住宿→计费」闭环。"
action={{
label: '筛选待确认账单',
onClick: () => {
// 账单生成后即为 unpaid待支付状态
setFilterStatus('unpaid');
setBillGeneratedHint(false);
},
}}
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
title="生成账单"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,50 +78,100 @@ const DashboardPage: React.FC = () => {
}>({
queryKey: ['dashboard', period],
queryFn: async () => {
try {
const [s, rr, cr, g, co, cu] = await Promise.all([
api.get<DashboardStats>('/dashboard/stats'),
api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>(
'/dashboard/class-attendance-ranking',
),
api.get<GanttRoom[]>('/dashboard/gantt', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy'),
api.get<ClassroomUtilStats>('/dashboard/classroom-utilization'),
]);
return {
stats: validateResponse<DashboardStats>(dashboardStatsSchema, s),
roomRanking: validateResponse<Array<{ roomNumber: string; total: string }>>(
// 各数据接口独立加载:单个接口失败只影响对应模块,避免整页数据被清零
const settled = await Promise.allSettled([
api.get<DashboardStats>('/dashboard/stats'),
api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>(
'/dashboard/class-attendance-ranking',
),
api.get<GanttRoom[]>('/dashboard/gantt', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy'),
api.get<ClassroomUtilStats>('/dashboard/classroom-utilization'),
]);
const value = <T,>(r: PromiseSettledResult<T>): T | null =>
r.status === 'fulfilled' ? r.value : null;
const rejected = settled.filter((r) => r.status === 'rejected');
if (rejected.length === settled.length) {
// 全部失败:抛出让 react-query 自动重试
console.error('看板数据加载失败', rejected);
throw new Error('看板数据加载失败');
}
if (rejected.length > 0) {
console.error('部分看板数据加载失败', rejected);
message.warning(`${rejected.length} 项数据加载失败,其余数据已正常显示`);
}
let stats: DashboardStats | null = null;
const s = value(settled[0]);
if (s) {
try {
stats = validateResponse<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,
rr,
),
classRanking: validateResponse<{
);
} catch (e) {
console.error(e);
}
}
let classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] } = {
top: [],
bottom: [],
};
const cr = value(settled[2]);
if (cr) {
try {
classRanking = validateResponse<{
top: ClassAttendanceRank[];
bottom: ClassAttendanceRank[];
}>(classAttendanceRankingSchema, cr),
ganttData: validateResponse<GanttRoom[]>(ganttRoomsSchema, g),
classroomOccupancy: validateResponse<ClassroomOccupancy[]>(
}>(classAttendanceRankingSchema, cr);
} catch (e) {
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,
co,
),
classroomUtil: validateResponse<ClassroomUtilStats>(classroomUtilStatsSchema, cu),
};
} catch (e) {
console.error(e);
message.error('数据加载失败,请稍后重试');
return {
stats: null,
classRanking: { top: [], bottom: [] },
classroomOccupancy: [],
ganttData: [],
roomRanking: [],
classroomUtil: null,
};
);
} catch (e) {
console.error(e);
}
}
let classroomUtil: ClassroomUtilStats | null = null;
const cu = value(settled[5]);
if (cu) {
try {
classroomUtil = validateResponse<ClassroomUtilStats>(classroomUtilStatsSchema, cu);
} catch (e) {
console.error(e);
}
}
return { stats, classRanking, classroomOccupancy, ganttData, roomRanking, classroomUtil };
},
});
const stats = fetchResult.stats;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,16 +98,11 @@ const RoomVisualPage: React.FC = () => {
const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day');
const queryClient = useQueryClient();
const { data, isLoading, isFetching } = useQuery<any>({
const { data, isLoading, isFetching, isError } = useQuery<any>({
queryKey: ['rooms', 'visual', isHistorical, asOf],
queryFn: async () => {
try {
const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined;
return await api.get('/rooms/visual', { params });
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return null;
}
const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined;
return await api.get('/rooms/visual', { params });
},
});
const loading = isLoading || isFetching;
@@ -141,6 +136,23 @@ const RoomVisualPage: React.FC = () => {
}
};
if (isError && !data) {
return (
<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' }} />;
const rooms = data.rooms.filter((r: any) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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