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:
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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 || ''))}
|
||||
/>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
47
apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx
Normal file
47
apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
85
apps/admin/src/components/AiChat/useSubmissionState.ts
Normal file
85
apps/admin/src/components/AiChat/useSubmissionState.ts
Normal 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 };
|
||||
}
|
||||
69
apps/admin/src/components/AiChat/welcomeCopy.ts
Normal file
69
apps/admin/src/components/AiChat/welcomeCopy.ts
Normal 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;
|
||||
}
|
||||
52
apps/admin/src/components/AppErrorBoundary.tsx
Normal file
52
apps/admin/src/components/AppErrorBoundary.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
69
apps/admin/src/components/NextStepHint.tsx
Normal file
69
apps/admin/src/components/NextStepHint.tsx
Normal 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;
|
||||
156
apps/admin/src/components/Onboarding/RoleTour.tsx
Normal file
156
apps/admin/src/components/Onboarding/RoleTour.tsx
Normal 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;
|
||||
35
apps/admin/src/components/QueryState/QueryEmpty.tsx
Normal file
35
apps/admin/src/components/QueryState/QueryEmpty.tsx
Normal 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;
|
||||
56
apps/admin/src/components/QueryState/QueryErrorState.tsx
Normal file
56
apps/admin/src/components/QueryState/QueryErrorState.tsx
Normal 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;
|
||||
4
apps/admin/src/components/QueryState/index.ts
Normal file
4
apps/admin/src/components/QueryState/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { QueryErrorState } from './QueryErrorState';
|
||||
export type { QueryErrorStateProps } from './QueryErrorState';
|
||||
export { QueryEmpty } from './QueryEmpty';
|
||||
export type { QueryEmptyProps, QueryEmptyAction } from './QueryEmpty';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
10
apps/admin/src/components/routeKeeperContext.ts
Normal file
10
apps/admin/src/components/routeKeeperContext.ts
Normal 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);
|
||||
37
apps/admin/src/hooks/usePageVisible.ts
Normal file
37
apps/admin/src/hooks/usePageVisible.ts
Normal 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]);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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="生成账单"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? '编辑班级' : '创建班级'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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="租赁详情"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }]}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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="查询同步状态..." />
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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('密码已重置');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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`)
|
||||
仍可渲染——建议先迁移历史数据或保留兼容读取。
|
||||
|
||||
Reference in New Issue
Block a user