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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { Alert } from 'antd';
interface ArtifactErrorBoundaryProps {
children: React.ReactNode;
/** 制品标题(用于错误提示文案) */
title?: string;
}
interface ArtifactErrorBoundaryState {
hasError: boolean;
}
/**
* A2UI 制品(表单/审查卡/图表)渲染错误兜底:单个制品渲染失败只降级为
* 错误占位卡片,不影响同气泡内其他消息与制品。
*/
export class ArtifactErrorBoundary extends React.Component<
ArtifactErrorBoundaryProps,
ArtifactErrorBoundaryState
> {
state: ArtifactErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): ArtifactErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[ArtifactErrorBoundary] 制品渲染异常:', error, info.componentStack);
}
render(): React.ReactNode {
if (this.state.hasError) {
return (
<Alert
type="warning"
showIcon
title={this.props.title ? `${this.props.title}渲染失败` : '此内容渲染失败'}
description="请让 AI 重新生成,或刷新后重试。"
/>
);
}
return this.props.children;
}
}
export default ArtifactErrorBoundary;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,85 @@
import { useCallback, useRef, useState } from 'react';
import type { XAgentCommand_v0_9 } from '@ant-design/x-card';
/**
* 提交状态管理:收敛 DynamicForm / DynamicReview 中重复的
* submitting / submitted / error 状态与「防重复提交 + 失败可重试」逻辑。
*
* 用法:
* const { submitting, submitted, error, run } = useSubmissionState();
* const handleSubmit = (values) => run(async () => { await onSubmit(values); });
*/
export function useSubmissionState() {
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const submittingRef = useRef(false);
const run = useCallback(async (task: () => Promise<void> | void) => {
if (submittingRef.current) return;
submittingRef.current = true;
setSubmitting(true);
setError(null);
try {
await task();
setSubmitted(true);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
} finally {
submittingRef.current = false;
setSubmitting(false);
}
}, []);
const reset = useCallback(() => {
setSubmitted(false);
setError(null);
}, []);
return { submitting, submitted, error, run, reset };
}
/**
* A2UI surface 的 XCard commands 增量更新生命周期:
* 每个 surface 只创建一次,后续通过 updateDataModel / updateComponents 增量更新。
*
* 用法:
* const { pushCommands } = useXCardSurface(surfaceId);
* useEffect(() => {
* pushCommands([
* { version: 'v0.9', createSurface: { surfaceId, catalogId } },
* { version: 'v0.9', updateDataModel: { surfaceId, path: '/x', value } },
* { version: 'v0.9', updateComponents: { surfaceId, components } },
* ]);
* }, [value]);
*/
export function useXCardSurface(surfaceId: string) {
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
const idRef = useRef<string>('');
const pushCommands = useCallback(
(cmds: XAgentCommand_v0_9[]) => {
if (cmds.length === 0) return;
// 同一 surface 的 createSurface 命令只允许出现一次,自动去重
const hasSurface = commandsRef.current.some(
(c) => 'createSurface' in c && c.createSurface.surfaceId === surfaceId,
);
const filtered = hasSurface
? cmds.filter((c) => !('createSurface' in c))
: cmds;
commandsRef.current = [...commandsRef.current, ...filtered];
setCommands([...commandsRef.current]);
},
[surfaceId],
);
const surfaceKey = surfaceId;
if (idRef.current !== surfaceKey) {
// 组件复用到新 surface 时,清空历史命令重新初始化
commandsRef.current = [];
idRef.current = surfaceKey;
}
return { commands, pushCommands };
}

View File

@@ -0,0 +1,69 @@
import { getRoleDomains } from '../../auth/menu-policy';
/**
* 按用户角色生成 AI 助手欢迎语,引导用户使用与其岗位匹配的业务闭环。
* 优先级:教师 > 住宿运营 > 教务 > 教室运营 > 系统/超管 > 兜底。
*/
export function welcomeDescription(roles: readonly string[], permissions: readonly string[]): string {
const domains = getRoleDomains(roles, permissions);
if (domains.has('teacher')) {
return '我可以帮你查询今日课程、拉取钉钉考勤、查看排课。课程开始后就能看到打卡结果。';
}
if (domains.has('accommodation')) {
return '我可以帮你完成「宿舍档案 → 学生 → 入住 → 费用 → 账单」的住宿计费闭环,先告诉我你手头有什么数据。';
}
if (domains.has('academic')) {
return '我可以帮你完成「学生档案 → 分班 → 排课 → 考勤」的教学闭环,支持 Excel 批量导入与预览确认。';
}
if (domains.has('classroom')) {
return '我可以帮你管理教室排期与租赁订单,查询占用情况,避免时间冲突。';
}
if (domains.has('system') || domains.has('super')) {
return '我是恭学 AI 助手。我可以查询经营数据、管理业务数据、生成批量导入预览——所有写操作都会先经你确认。';
}
return '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。';
}
/**
* 与角色匹配的业务闭环引导示例(用于 Prompts 建议话术)。
* 返回空数组表示当前角色无匹配示例。
*/
export function workflowPromptExamples(
roles: readonly string[],
permissions: readonly string[],
): { label: string; description: string }[] {
const domains = getRoleDomains(roles, permissions);
const examples: { label: string; description: string }[] = [];
if (domains.has('academic')) {
examples.push(
{ label: '帮我从 Excel 导入学生并完成分班', description: '教学闭环' },
{ label: '查一下这周有哪些班级还没排课', description: '教学闭环' },
);
}
if (domains.has('accommodation')) {
examples.push(
{ label: '帮我从 Excel 导入学生并安排入住', description: '住宿计费闭环' },
{ label: '查一下本月还没生成账单的入住学生', description: '住宿计费闭环' },
);
}
if (domains.has('classroom')) {
examples.push(
{ label: '查一下这间教室本周的占用情况', description: '教室运营' },
);
}
if (domains.has('teacher')) {
examples.push(
{ label: '今天我有哪几节课?', description: '今日教学' },
);
}
if (domains.has('system') || domains.has('super')) {
examples.push(
{ label: '看一下本月的经营概览', description: '数据面板' },
{ label: '帮我梳理宿舍计费的完整流程', description: '业务流程' },
);
}
return examples;
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Button, Result } from 'antd';
interface AppErrorBoundaryProps {
children: React.ReactNode;
/** 自定义降级内容;不传则使用默认错误卡片 */
fallback?: React.ReactNode;
}
interface AppErrorBoundaryState {
hasError: boolean;
}
/**
* 全局渲染错误兜底:捕获子树内的渲染异常,展示可恢复的错误卡片,
* 避免单个页面/组件崩溃导致整个应用白屏。
*/
export class AppErrorBoundary extends React.Component<
AppErrorBoundaryProps,
AppErrorBoundaryState
> {
state: AppErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): AppErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[AppErrorBoundary] 渲染异常:', error, info.componentStack);
}
render(): React.ReactNode {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<Result
status="error"
title="页面出现异常"
subTitle="请刷新页面重试;若问题持续,请联系管理员。"
extra={
<Button type="primary" onClick={() => window.location.reload()}>
</Button>
}
/>
);
}
return this.props.children;
}
}
export default AppErrorBoundary;

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useImmer } from 'use-immer';
import { Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd';
import { App, Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd';
import { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
import api from '../api';
import { message } from '../ui/app-message';
@@ -30,6 +30,7 @@ interface MatchModalProps {
}
const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => {
const { modal } = App.useApp();
const { hasPermission, hasAllPermissions, permissionsReady } = usePermission();
const canTriggerSync = hasPermission('sync:trigger');
const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger');
@@ -168,6 +169,22 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
};
const handleClose = () => {
// 已进入匹配步骤且存在待处理数据时,关闭会丢失全部决策,需先确认
const hasPendingWork = step === 'match' && (entries.length > 0 || decisions.size > 0);
if (hasPendingWork) {
modal.confirm({
title: '放弃当前匹配?',
content: '已确认的匹配决策将全部丢失,且不会写入系统。',
okText: '放弃',
okButtonProps: { danger: true },
cancelText: '继续匹配',
onOk: () => {
reset();
onClose();
},
});
return;
}
reset();
onClose();
};
@@ -330,6 +347,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
onCancel={handleClose}
width={step === 'match' || step === 'applying' ? 900 : 640}
mask={{ closable: false }}
closable={step !== 'applying'}
footer={
step === 'connection'
? [
@@ -380,7 +398,12 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
{step === 'rule' ? renderRuleStep() : null}
{step === 'match' ? renderMatchStep() : null}
{step === 'applying' ? (
<Spin description="正在同步..." style={{ display: 'block', margin: '48px auto' }} />
<>
<Spin description="正在同步,请勿关闭窗口..." style={{ display: 'block', margin: '48px auto' }} />
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>
</Typography.Text>
</>
) : null}
</Modal>
);

View File

@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { Button, Card, Flex, Typography } from 'antd';
import { CloseOutlined, RightOutlined, StepForwardOutlined } from '@ant-design/icons';
export interface NextStepHintProps {
/** 提示标题,如「下一步:分班」 */
title: string;
/** 补充说明 */
description?: string;
/** 主操作按钮(跳转到下一步) */
action?: { label: string; onClick: () => void };
/** 是否可关闭,默认 true */
closable?: boolean;
/** 关闭回调 */
onClose?: () => void;
}
/**
* 「下一步」引导卡片:在操作成功后或空状态下提示用户业务闭环的下一步,
* 让用户始终知道接下来该做什么。
*/
export const NextStepHint: React.FC<NextStepHintProps> = ({
title,
description,
action,
closable = true,
onClose,
}) => {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<Card
size="small"
className="next-step-hint"
style={{ marginBottom: 16, borderColor: '#b7d4ff', background: '#f0f7ff' }}
styles={{ body: { padding: '10px 16px' } }}
>
<Flex align="center" justify="space-between" gap={8} wrap>
<Flex align="center" gap={8} wrap>
<StepForwardOutlined style={{ color: '#1677ff' }} />
<Typography.Text strong>{title}</Typography.Text>
{description ? <Typography.Text type="secondary">{description}</Typography.Text> : null}
</Flex>
<Flex gap={4} align="center">
{action ? (
<Button type="primary" size="small" onClick={action.onClick}>
{action.label} <RightOutlined />
</Button>
) : null}
{closable ? (
<Button
type="text"
size="small"
icon={<CloseOutlined />}
aria-label="关闭提示"
onClick={() => {
setDismissed(true);
onClose?.();
}}
/>
) : null}
</Flex>
</Flex>
</Card>
);
};
export default NextStepHint;

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { Button, Result, Typography } from 'antd';
export interface QueryErrorStateProps {
/** 错误标题,默认「数据加载失败」 */
title?: string;
/** 错误描述,默认「请检查网络后重试」 */
description?: string;
/** 点击重试回调;不传则不显示重试按钮 */
onRetry?: () => void;
/** 紧凑模式:用于表格内部、弹窗等空间受限场景 */
compact?: boolean;
}
/**
* 统一查询错误态:任何数据加载失败都应渲染本组件(而非伪装成空状态),
* 并提供重试入口,让用户明确知道「加载失败」而非「没有数据」。
*/
export const QueryErrorState: React.FC<QueryErrorStateProps> = ({
title = '数据加载失败',
description = '请检查网络后重试。',
onRetry,
compact = false,
}) => {
if (compact) {
return (
<div style={{ padding: '32px 16px', textAlign: 'center' }}>
<Typography.Text type="secondary">{title}</Typography.Text>
{description ? (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{description}
</Typography.Text>
</div>
) : null}
{onRetry ? (
<div style={{ marginTop: 12 }}>
<Button size="small" onClick={onRetry}>
</Button>
</div>
) : null}
</div>
);
}
return (
<Result
status="warning"
title={title}
subTitle={description}
extra={onRetry ? <Button type="primary" onClick={onRetry}></Button> : undefined}
/>
);
};
export default QueryErrorState;

View File

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

View File

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

View File

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

View File

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