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);
|
||||
Reference in New Issue
Block a user