diff --git a/.env.example b/.env.example index dc86107e..1fd26cc9 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,11 @@ AI_CONFIG_ENCRYPTION_KEY= # 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl(仅内网部署使用) # AI_ALLOW_PRIVATE_BASE_URL=true + +# ---- 安全 ---- +# 是否信任反向代理的 X-Forwarded-For / X-Real-IP(仅当部署在可信代理/Nginx 后时才设 true; +# 不设置时服务端只用 TCP socket 地址,防止伪造客户端 IP) +# TRUST_PROXY=true + +# 说明:第三方集成配置(钉钉/企微 appSecret)的静态加密复用 AI_CONFIG_ENCRYPTION_KEY, +# 存量明文可用 `cd apps/server && npm run encrypt:integration-secrets` 一次性加密。 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fd42e20c..aa8958ac 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -16,6 +16,10 @@ name: PM2 部署 on: workflow_dispatch: +concurrency: + group: deploy + cancel-in-progress: false + jobs: deploy: runs-on: ubuntu-latest @@ -37,7 +41,10 @@ jobs: - name: 配置 SSH run: | mkdir -p ~/.ssh - echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key + # 单引号 EOF 防止 key 中的 $ / 反引号被 shell 插值(YAML 会剥掉缩进,bash 实际收到列首 EOF) + cat > ~/.ssh/deploy_key <<'EOF' + ${{ secrets.SSH_PRIVATE_KEY }} + EOF chmod 600 ~/.ssh/deploy_key cat >> ~/.ssh/config <<'EOF' Host deploy-server @@ -59,17 +66,20 @@ jobs: --exclude='.turbo/' \ --exclude='.claude/' \ --exclude='.codegraph/' \ + --exclude='.env*' \ + --exclude='data.sql' \ + --exclude='uploads/' \ ./ deploy-server:${{ secrets.REMOTE_DIR }}/ - name: 安装依赖 → 迁移 → PM2 重载 run: | ssh deploy-server " + set -e cd ${{ secrets.REMOTE_DIR }} mkdir -p logs - if [ ! -d node_modules ]; then - echo '首次部署,安装生产依赖...' - npm ci --omit=dev - fi + echo '安装依赖...' + # 注意:migration:run 依赖 ts-node/tsconfig-paths(devDependencies),不能 --omit=dev + npm ci echo '执行数据库迁移...' npm run migration:run -w @gongxue/server echo 'PM2 重载...' diff --git a/README.md b/README.md index c8bcc0f9..e4d8bedf 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,11 @@ | 账单导出 | Excel(汇总+明细双Sheet)、单条PDF账单 | | 教室管理 | 教室信息维护、教室租赁记录 | | 押金管理 | 押金收取与退还 | +| 班级/排课 | 班级档案、分班、教室日程与排课 | +| 考勤管理 | 手工考勤、钉钉考勤同步、自动匹配 | +| 教室租赁 | 租赁订单、合同、租赁日程 | +| AI 助手 | 对话式查询、表单/导入向导/图表、业务待办引导 | +| 组织/校区 | 组织机构与数据范围 | | 操作日志 | 所有涉及钱的操作自动审计留痕 | | 账号管理 | 用户增删改查、角色区分、启用/禁用、重置密码 | @@ -42,7 +47,7 @@ ### 后端启动 ```bash -cd backend +cd apps/server cp .env.example .env # 复制并修改环境配置 npm install npm run start:dev # 开发模式启动,默认端口 3000 @@ -51,46 +56,48 @@ npm run start:dev # 开发模式启动,默认端口 3000 ### 前端启动 ```bash -cd frontend +cd apps/admin npm install npm run dev # 开发模式启动,默认端口 5173 ``` -### Docker 部署 +### 常用命令 ```bash -docker-compose up -d # 一键启动 MySQL + 后端 + 前端 +npm run typecheck # 全仓类型检查 +npm run lint # 全仓 lint +npm run test # 全仓测试 +npm run build # 全仓构建 ``` ## 项目结构 ``` -├── backend/ # 后端 NestJS 服务 +├── apps/server/ # 后端 NestJS 服务 │ ├── src/ -│ │ ├── auth/ # 认证模块 (JWT) +│ │ ├── ai-chat/ # AI 对话、表单/导入向导/图表 +│ │ ├── attendance/ # 考勤与钉钉同步 │ │ ├── bills/ # 账单模块 -│ │ ├── classrooms/ # 教室管理 │ │ ├── dashboard/ # 数据面板 -│ │ ├── deposits/ # 押金管理 │ │ ├── entities/ # 数据实体 -│ │ ├── expenses/ # 费用录入 │ │ ├── occupancies/# 入住管理 +│ │ ├── rbac/ # 角色权限 │ │ ├── rooms/ # 宿舍管理 -│ │ ├── students/ # 学生管理 -│ │ └── tenants/ # 租户管理 +│ │ └── students/ # 学生管理 │ └── .env.example # 环境配置模板 -├── frontend/ # 前端 React 应用 +├── apps/admin/ # 前端 React 应用 │ └── src/ │ ├── api/ # API 请求封装 +│ ├── components/ # 通用组件 │ ├── layouts/ # 布局组件 │ └── pages/ # 页面组件 -├── docker-compose.yml # Docker 编排配置 +├── packages/ # 共享配置包 └── 技术文档.md # 详细技术文档 ``` ## 环境配置 -复制 `backend/.env.example` 为 `backend/.env`,按需修改: +复制 `apps/server/.env.example` 为 `apps/server/.env`,按需修改: | 配置项 | 说明 | 默认值 | |--------|------|--------| diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index f17a2647..d032245d 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -7,6 +7,7 @@ import zhCN from 'antd/es/locale/zh_CN'; import MainLayout from './layouts/MainLayout'; import PermissionRoute from './components/PermissionRoute'; import DefaultRoute from './components/DefaultRoute'; +import ScrollToTop from './components/ScrollToTop'; import AppMessageBridge from './ui/AppMessageBridge'; import { useUserStore } from './store/user/userStore'; @@ -74,6 +75,7 @@ const App: React.FC = () => { + diff --git a/apps/admin/src/api/imports.ts b/apps/admin/src/api/imports.ts index 635e8c2a..e6912374 100644 --- a/apps/admin/src/api/imports.ts +++ b/apps/admin/src/api/imports.ts @@ -21,6 +21,8 @@ export async function createImportRun( conversationId?: number; stages?: ImportStageRequest[]; mapping?: Record>; + /** 上传进度回调(0-100) */ + onProgress?: (percent: number) => void; }, ): Promise { const form = new FormData(); @@ -31,7 +33,12 @@ export async function createImportRun( if (options.mapping && Object.keys(options.mapping).length > 0) { form.append('mapping', JSON.stringify(options.mapping)); } - const res = await api.post>('/imports/runs', form); + const res = await api.post>('/imports/runs', form, { + onUploadProgress: (event) => { + if (!options.onProgress || !event.total) return; + options.onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100)); + }, + }); return res.data; } diff --git a/apps/admin/src/api/queryClient.ts b/apps/admin/src/api/queryClient.ts new file mode 100644 index 00000000..d0bb8074 --- /dev/null +++ b/apps/admin/src/api/queryClient.ts @@ -0,0 +1,20 @@ +import { QueryClient } from '@tanstack/react-query'; + +/** + * 全局 QueryClient:统一缓存/重试策略。 + * - retry 1:接口失败最多重试 1 次,避免瞬时错误直接白屏 + * - staleTime 30s:30 秒内重复请求走缓存 + * - gcTime 5min:不活跃缓存 5 分钟后回收 + * - refetchOnWindowFocus false:切回窗口不自动全量刷新, + * 保活页面由 useVisibleRefetch 按需刷新,避免重复请求 + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: 30_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/apps/admin/src/api/queryKeys.ts b/apps/admin/src/api/queryKeys.ts new file mode 100644 index 00000000..fabb82b6 --- /dev/null +++ b/apps/admin/src/api/queryKeys.ts @@ -0,0 +1,145 @@ +/** + * 统一 QueryKey 工厂。 + * + * 每个模块一个命名空间,key 由工厂函数生成: + * - 避免散落字符串字面量导致拼写不一致、缓存串扰 + * - invalidate / refetch / prefetch 与 useQuery 使用同一来源,不会对不上 + * + * 约定: + * - `all` 是该模块的「根 key」,用于整体失效(invalidateQueries 会匹配前缀) + * - 列表 key 按过滤条件展开;详情/子资源用固定段 + 参数 + */ +export const queryKeys = { + students: { + all: ['students'] as const, + list: (filters: { + search?: string; + status?: string; + archived?: boolean; + organizationId?: number; + classId?: number; + teacherId?: number; + }) => ['students', filters] as const, + organizations: (canView: boolean) => ['students', 'organizations', canView] as const, + filterLookups: () => ['students', 'filter-lookups'] as const, + }, + classes: { + all: ['classes'] as const, + list: (filters: { status?: string; type?: string; archived?: boolean }) => + ['classes', filters] as const, + detail: (id: number) => ['classes', 'detail', id] as const, + schedule: (id: number, dateRange: unknown) => ['classes', 'schedule', id, dateRange] as const, + attendanceSummary: (id: number, dateRange: unknown) => + ['classes', 'attendance-summary', id, dateRange] as const, + }, + classSchedules: { + all: ['class-schedules'] as const, + list: (params: { startDate?: string; endDate?: string; classroomIds?: unknown[] }) => + ['class-schedules', params] as const, + }, + classrooms: { + all: ['classrooms'] as const, + list: (archived: boolean) => ['classrooms', archived] as const, + }, + classroomRentals: { + all: ['classroom-rentals'] as const, + list: (month?: string) => ['classroom-rentals', month] as const, + schedule: (year: number, month: number) => + ['classroom-rentals', 'schedule', year, month] as const, + meta: () => ['classroom-rentals', 'meta'] as const, + }, + organizations: { + all: ['organizations'] as const, + list: () => ['organizations'] as const, + options: () => ['organizations', 'options'] as const, + }, + rbac: { + all: ['rbac'] as const, + users: (archived: boolean) => ['rbac', 'users', archived] as const, + allUsers: () => ['rbac', 'users', 'all'] as const, + teachers: (params: { page: number; pageSize: number; search?: string }) => + ['rbac', 'teachers', params] as const, + teacherWorkspace: () => ['rbac', 'teacher-workspace'] as const, + roles: () => ['rbac', 'roles'] as const, + permissionTree: () => ['rbac', 'roles', 'permission-tree'] as const, + permissionsTree: () => ['rbac', 'permissions', 'tree'] as const, + }, + expenses: { + all: ['expenses'] as const, + list: (archived: boolean) => ['expenses', archived ? 'archived' : 'active'] as const, + }, + expenseTypes: { + map: () => ['expense-types', 'map'] as const, + }, + expenseLookups: { + all: ['expense-lookups'] as const, + }, + bills: { + all: ['bills'] as const, + list: (filters: { status?: string; expenseType?: string }) => ['bills', filters] as const, + }, + wallets: { + all: ['wallets'] as const, + list: (params: { keyword?: string; debtOnly?: boolean; roomType?: string }) => + ['wallets', params] as const, + transactions: (studentId: number) => ['wallets', 'transactions', studentId] as const, + roomTypes: () => ['wallets', 'room-types'] as const, + }, + deposits: { + all: ['deposits'] as const, + list: () => ['deposits'] as const, + eligible: (roomType?: string) => ['deposits', 'eligible', roomType] as const, + }, + occupancies: { + all: ['occupancies'] as const, + list: (params: { viewMode?: string; dateRange?: unknown }) => ['occupancies', params] as const, + }, + rooms: { + all: ['rooms'] as const, + overview: (archived: boolean) => ['rooms', 'overview', archived] as const, + visual: (params: { historical: boolean; asOf?: unknown }) => + ['rooms', 'visual', params] as const, + }, + exams: { + all: ['exams'] as const, + detail: (id: number) => ['exams', 'detail', id] as const, + classes: () => ['exams', 'classes'] as const, + }, + operationLogs: { + all: ['operation-logs'] as const, + list: (params: { page: number; pageSize: number; module?: string; dateRange?: unknown }) => + ['operation-logs', params] as const, + }, + attendance: { + all: ['attendance'] as const, + workspace: () => ['attendance', 'workspace'] as const, + syncStatus: () => ['attendance', 'sync-status'] as const, + schedules: (classId: number, date: string) => ['attendance', 'schedules', classId, date] as const, + meta: { + periods: () => ['attendance', 'meta', 'periods'] as const, + classes: () => ['attendance', 'meta', 'classes'] as const, + alerts: () => ['attendance', 'meta', 'alerts'] as const, + }, + }, + attendanceDevices: { + all: ['attendance-devices'] as const, + }, + dashboard: { + all: ['dashboard'] as const, + summary: (period: unknown) => ['dashboard', period] as const, + }, + integration: { + config: () => ['integration', 'config'] as const, + }, + sync: { + jinshujuRules: () => ['sync', 'jinshuju', 'rules'] as const, + }, + archive: { + detail: (studentId: number) => ['archive', studentId] as const, + }, + ai: { + config: () => ['ai', 'config'] as const, + }, +} as const; + +export type QueryKeys = typeof queryKeys; diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx index 7ed21b68..8a159236 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx @@ -64,6 +64,14 @@ export const AiChatSidebar: React.FC = ({ } /> {loadingList && } + {!loadingList && !selectionMode && conversationCount === 0 ? ( +
+ 暂无会话 + + 点击「新对话」开始提问 + +
+ ) : null}
{selectionMode ? ( <> diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.tsx index c179270d..1ba62d90 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.tsx @@ -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 = ({ 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); @@ -184,11 +189,32 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting const switchConversation = useCallback( (key: string) => { - discardPendingAttachments(); - if (isMobile) setSidebarOpen(false); - setActiveConversationKey(key); + const doSwitch = () => { + discardPendingAttachments(); + if (isMobile) setSidebarOpen(false); + setActiveConversationKey(key); + }; + // 有待发送的附件时先确认,避免静默删除已上传文件 + if (uploadItems.length > 0) { + modal.confirm({ + title: '切换会话将丢弃未发送的附件', + content: `当前有 ${uploadItems.length} 个已上传但未发送的附件,切换会话后将被删除,此操作不可恢复。`, + okText: '切换并丢弃', + okButtonProps: { danger: true }, + cancelText: '留在当前会话', + onOk: doSwitch, + }); + return; + } + doSwitch(); }, - [discardPendingAttachments, isMobile, setActiveConversationKey], + [ + discardPendingAttachments, + isMobile, + modal, + setActiveConversationKey, + uploadItems.length, + ], ); useEffect( @@ -524,12 +550,21 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting title="你好,我是恭学 AI 助手" description={ lockedSkill?.description || - '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。' + welcomeDescription(user?.roles ?? [], permissions) } /> ({ + key: `workflow-${index}`, + label: item.label, + description: item.description, + }), + ), + ]} wrap onItemClick={({ data }) => submit(String(data.label || ''))} /> diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index f176047d..554d0e13 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -13,9 +13,12 @@ import type { ThoughtChainItemType } from '@ant-design/x'; import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown'; import { Alert, Button, Flex, Input, Space, Typography } from 'antd'; import { useUserStore } from '../../store/user/userStore'; +import { message } from '../../ui/app-message'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; +import { deriveCharts, deriveForms, deriveReviews } from './uiArtifacts'; +import { ArtifactErrorBoundary } from './ArtifactErrorBoundary'; import { LiteCodeHighlighter } from './LiteCodeHighlighter'; import { LiteMermaid } from './LiteMermaid'; import type { @@ -41,7 +44,6 @@ const toolLabels: Record = { search_bills: '查询账单', get_dashboard_stats: '读取经营概览', render_form: '生成表单', - render_review: '生成导入预览', render_chart: '生成图表', start_import_wizard: '生成导入向导', create_student: '创建学生', @@ -103,6 +105,24 @@ async function openSourceUrl(item: { url?: string }): Promise { window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); } +/** 打开附件,失败时给出明确提示(避免「点了没反应」) */ +async function handleOpenAttachment(attachment: AiAttachment): Promise { + try { + await openAttachment(attachment); + } catch (error: unknown) { + message.error(error instanceof Error ? error.message : '附件打开失败,请重试'); + } +} + +/** 打出来源链接,失败时给出明确提示 */ +async function handleOpenSource(item: { url?: string }): Promise { + try { + await openSourceUrl(item); + } catch (error: unknown) { + message.error(error instanceof Error ? error.message : '来源打开失败,请重试'); + } +} + function ToolChain({ tools }: { tools: AiToolRun[] }) { const items = useMemo( () => @@ -206,6 +226,11 @@ export const AiMessageContent: React.FC = ({ const streaming = status === 'loading' || status === 'updating'; const formSubmission = message.metadata?.a2uiSubmit; const reviewSubmission = message.metadata?.a2uiReviewSubmit; + // 统一 artifact 优先,历史消息(仅 legacy 字段)回退 + const forms = deriveForms(message).length > 0 ? deriveForms(message) : (message.forms ?? []); + const reviews = + deriveReviews(message).length > 0 ? deriveReviews(message) : (message.reviews ?? []); + const charts = deriveCharts(message).length > 0 ? deriveCharts(message) : (message.charts ?? []); const sourceMeta = message.metadata?.a2uiSources; const sourceItems = Array.isArray(sourceMeta) ? sourceMeta @@ -227,7 +252,7 @@ export const AiMessageContent: React.FC = ({ byte={attachment.size} size="small" icon={attachmentIcon(attachment)} - onClick={() => void openAttachment(attachment)} + onClick={() => void handleOpenAttachment(attachment)} /> )); @@ -351,30 +376,34 @@ export const AiMessageContent: React.FC = ({ void openSourceUrl(item as { url?: string })} + onClick={(item) => void handleOpenSource(item as { url?: string })} /> )} - {(message.forms ?? []).map((form) => ( - onSubmitForm?.(form, values)} - /> + {(forms ?? []).map((form) => ( + + onSubmitForm?.(form, values)} + /> + ))} - {(message.reviews ?? []).map((review: AiReviewSchema) => ( - onSubmitReview?.(reviewId, review.title)} - onConfirmStep={onConfirmReviewStep} - onConfirmGroup={onConfirmReviewGroup} - /> + {(reviews ?? []).map((review: AiReviewSchema) => ( + + onSubmitReview?.(reviewId, review.title)} + onConfirmStep={onConfirmReviewStep} + onConfirmGroup={onConfirmReviewGroup} + /> + ))} - {(message.charts ?? []).map((chart: AiChartSchema) => ( - + {(charts ?? []).map((chart: AiChartSchema) => ( + + + ))} {message.error && } {message.cancelled && 回答已停止} diff --git a/apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx b/apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx new file mode 100644 index 00000000..71fb3864 --- /dev/null +++ b/apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx @@ -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 ( + + ); + } + return this.props.children; + } +} + +export default ArtifactErrorBoundary; diff --git a/apps/admin/src/components/AiChat/DynamicChart.tsx b/apps/admin/src/components/AiChat/DynamicChart.tsx index fc9c78ef..ee3e1f3c 100644 --- a/apps/admin/src/components/AiChat/DynamicChart.tsx +++ b/apps/admin/src/components/AiChat/DynamicChart.tsx @@ -1,10 +1,12 @@ -import React, { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react'; +import { saveAs } from 'file-saver'; 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')); @@ -199,9 +201,35 @@ interface ChartPreviewProps { * renders an ECharts option built from it. */ const ChartPreview: React.FC = ({ chart }) => { - const option = useMemo(() => (chart ? buildOption(chart) : {}), [chart]); + // 空/无数据时先短路,避免 buildOption 在空数据集上执行 + const hasData = !!chart && !!chart.rows && chart.rows.length > 0; + const option = useMemo( + () => (chart && hasData ? buildOption(chart) : {}), + [chart, hasData], + ); const [instance, setInstance] = useState(null); if (!chart) return null; + // 空数据集:渲染明确占位,而不是一张空白图 + if (!hasData) { + return ( +
+
+ {chart.title} + {CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType} +
+
+ 暂无数据 +
+
+ ); + } const downloadImage = () => { if (!instance) return; @@ -210,12 +238,7 @@ const ChartPreview: React.FC = ({ chart }) => { pixelRatio: 2, backgroundColor: '#fff', }); - const link = document.createElement('a'); - link.href = url; - link.download = `${chart.title || '图表'}.png`; - document.body.appendChild(link); - link.click(); - link.remove(); + saveAs(url, `${chart.title || '图表'}.png`); }; return ( @@ -254,46 +277,39 @@ export interface DynamicChartProps { * so history replays identically. */ export const DynamicChart: React.FC = ({ chart }) => { - const commandsRef = useRef([]); - const [commands, setCommands] = useState([]); - const idRef = useRef(''); + 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 (
diff --git a/apps/admin/src/components/AiChat/DynamicForm.tsx b/apps/admin/src/components/AiChat/DynamicForm.tsx index 8148f58d..58d4eeb5 100644 --- a/apps/admin/src/components/AiChat/DynamicForm.tsx +++ b/apps/admin/src/components/AiChat/DynamicForm.tsx @@ -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 = ({ form, disabled, onSubmit }) => { - const [submitting, setSubmitting] = useState(false); - const [submitted, setSubmitted] = useState(false); - const [error, setError] = useState(null); - const commandsRef = useRef([]); - const [commands, setCommands] = useState([]); - const idRef = useRef(''); + 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) => { - if (submitting) return; - setSubmitting(true); - setError(null); - try { + const handleSubmit = (values: Record) => { + void run(async () => { await onSubmit(values); - setSubmitted(true); - } catch (reason) { - setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试'); - } finally { - setSubmitting(false); - } + }); }; const handleAction = (payload: ActionPayload) => { diff --git a/apps/admin/src/components/AiChat/DynamicReview.tsx b/apps/admin/src/components/AiChat/DynamicReview.tsx index 0f4a8de8..aec468a6 100644 --- a/apps/admin/src/components/AiChat/DynamicReview.tsx +++ b/apps/admin/src/components/AiChat/DynamicReview.tsx @@ -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 = ({ activeTypeRef.current = activeType; const [localReview, setLocalReview] = useState(review); const [error, setError] = useState(null); - const commandsRef = useRef([]); - const [commands, setCommands] = useState([]); - const idRef = useRef(''); + const sid = surfaceId(localReview.id); + const { commands, pushCommands } = useXCardSurface(sid); useEffect(() => { setLocalReview(review); @@ -455,55 +455,51 @@ export const DynamicReview: React.FC = ({ }, [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, diff --git a/apps/admin/src/components/AiChat/LiteMermaid.tsx b/apps/admin/src/components/AiChat/LiteMermaid.tsx index be2e32ca..6bea6d67 100644 --- a/apps/admin/src/components/AiChat/LiteMermaid.tsx +++ b/apps/admin/src/components/AiChat/LiteMermaid.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { useIsMounted } from 'usehooks-ts'; interface LiteMermaidProps { children: string; @@ -11,9 +12,9 @@ interface LiteMermaidProps { export function LiteMermaid({ children }: LiteMermaidProps) { const containerRef = useRef(null); const [error, setError] = useState(null); + const isMounted = useIsMounted(); useEffect(() => { - let cancelled = false; const container = containerRef.current; if (!container) return; @@ -22,21 +23,17 @@ export function LiteMermaid({ children }: LiteMermaidProps) { const mermaid = (await import('mermaid')).default; mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' }); const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children); - if (!cancelled) { + if (isMounted()) { const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); container.replaceChildren(doc.documentElement); setError(null); } } catch (e) { - if (!cancelled) { + if (isMounted()) { setError(e instanceof Error ? e.message : '图表渲染失败'); } } })(); - - return () => { - cancelled = true; - }; }, [children]); if (error) { diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts index bcbd8c3a..28e5fc44 100644 --- a/apps/admin/src/components/AiChat/api.ts +++ b/apps/admin/src/components/AiChat/api.ts @@ -30,12 +30,19 @@ export const aiChatApi = { `${basePath}/${conversationId}/messages/${messageId}`, ) ).data, - uploadAttachment: async (file: File): Promise => { + uploadAttachment: async ( + file: File, + onProgress?: (percent: number) => void, + ): Promise => { const form = new FormData(); form.append('file', file); return ( await api.post>('/ai/chat/attachments', form, { timeout: 120_000, + onUploadProgress: (event) => { + if (!onProgress || !event.total) return; + onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100)); + }, }) ).data; }, diff --git a/apps/admin/src/components/AiChat/bubble.integration.test.tsx b/apps/admin/src/components/AiChat/bubble.integration.test.tsx index 761bc732..7123e647 100644 --- a/apps/admin/src/components/AiChat/bubble.integration.test.tsx +++ b/apps/admin/src/components/AiChat/bubble.integration.test.tsx @@ -4,6 +4,7 @@ import { Bubble } from '@ant-design/x'; import { afterEach, describe, expect, it } from 'vitest'; import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer'; import { AiMessageContent } from './AiMessageContent'; +import { ArtifactErrorBoundary } from './ArtifactErrorBoundary'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; @@ -504,4 +505,49 @@ describe('AI chat bubble rendering', () => { expect(container.textContent).toContain(label); expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull(); }); + + it('renders an empty-data placeholder instead of a blank chart', async () => { + const chart: AiChartSchema = { + id: 'chart-empty', + title: '空图表', + chartType: 'bar', + columns: [{ key: 'name', title: '名称' }], + rows: [], + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + expect(container.textContent).toContain('空图表'); + expect(container.textContent).toContain('暂无数据'); + expect(container.querySelector('canvas')).toBeNull(); + }); + + it('degrades a single failing artifact to an error card without crashing the bubble', async () => { + const Bomb: React.FC = () => { + throw new Error('boom'); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + // 用无错误边界的兄弟节点 + 错误边界内的炸弹组件验证隔离 + await act(async () => { + root?.render( +
+
正常内容
+ + + +
, + ); + }); + + expect(container.querySelector('.neighbor')?.textContent).toContain('正常内容'); + expect(container.textContent).toContain('表单渲染失败'); + }); }); diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index 5363603f..fe998add 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -78,7 +78,7 @@ describe('AI chat history mapper', () => { expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' }); }); - it('restores uiArtifacts from message metadata and derives legacy lists', () => { + it('restores uiArtifacts from message metadata (统一协议)', () => { const mapped = mapHistoryMessage({ id: 8, role: 'assistant', @@ -120,8 +120,14 @@ describe('AI chat history mapper', () => { }); expect(mapped.message.uiArtifacts).toHaveLength(2); - expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-10', status: 'submitted' }); - expect(mapped.message.reviews?.[0]).toMatchObject({ id: 'review-10', status: 'expired' }); + expect(mapped.message.uiArtifacts?.[0].payload).toMatchObject({ + id: 'form-10', + status: 'submitted', + }); + expect(mapped.message.uiArtifacts?.[1].payload).toMatchObject({ + id: 'review-10', + status: 'expired', + }); }); it('restores a persisted A2UI review from message metadata', () => { diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index a0761d00..dcd28da9 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -123,38 +123,7 @@ describe('AI chat SSE message reducer', () => { expect(message.id).toBe(9); }); - it('merges ui.form events into the assistant message by id', () => { - const form = { - id: 'form-1', - title: '新增学生', - submitLabel: '提交创建', - fields: [ - { name: 'name', label: '姓名', type: 'input', required: true }, - { name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: 'male' }] }, - ], - }; - let message = reduceAiSseMessage(undefined, { - event: 'ui.form', - data: JSON.stringify({ messageId: 8, form }), - }); - message = reduceAiSseMessage(message, { - event: 'ui.form', - data: JSON.stringify({ messageId: 8, form: { ...form, id: 'form-1' } }), - }); - message = reduceAiSseMessage(message, { - event: 'ui.form', - data: JSON.stringify({ - messageId: 8, - form: { id: 'form-2', title: '入住确认', fields: [] }, - }), - }); - - expect(message.forms).toHaveLength(2); - expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' }); - expect(message.forms?.[1]).toMatchObject({ id: 'form-2' }); - }); - - it('merges ui.artifact events into uiArtifacts and legacy lists by id', () => { + it('merges ui.artifact events into uiArtifacts by id', () => { let message = reduceAiSseMessage(undefined, { event: 'ui.artifact', data: JSON.stringify({ @@ -185,8 +154,8 @@ describe('AI chat SSE message reducer', () => { }); expect(message.uiArtifacts).toHaveLength(2); - expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' }); - expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'expired' }); + expect(message.uiArtifacts?.[0].payload).toMatchObject({ id: 'form-1', title: '新增学生' }); + expect(message.uiArtifacts?.[1].payload).toMatchObject({ id: 'review-1', status: 'expired' }); }); @@ -213,43 +182,6 @@ describe('AI chat SSE message reducer', () => { expect(message.forms?.[0].id).toBe('form-9'); }); - it('merges ui.review events into the assistant message and updates by id', () => { - const review = { - id: 'review-1', - title: '开学导入', - summary: '来自报名 Excel', - status: 'pending', - sections: [ - { - key: 'students', - type: 'students', - title: '学生', - kind: 'table', - columns: [ - { key: 'name', title: '姓名' }, - { key: 'phone', title: '手机号' }, - ], - rows: [{ name: '张三', phone: '13800138000' }], - issues: [], - }, - ], - }; - let message = reduceAiSseMessage(undefined, { - event: 'ui.review', - data: JSON.stringify({ messageId: 8, review }), - }); - message = reduceAiSseMessage(message, { - event: 'ui.review', - data: JSON.stringify({ - messageId: 8, - review: { ...review, status: 'submitted', resultSummary: '{"students":{"created":1}}' }, - }), - }); - - expect(message.reviews).toHaveLength(1); - expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'submitted' }); - }); - it('shows model retrying state and clears it when content starts', () => { let message = reduceAiSseMessage(undefined, { event: 'model.retrying', @@ -301,37 +233,6 @@ describe('AI chat SSE message reducer', () => { expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' }); }); - it('merges ui.chart events into the assistant message by id', () => { - const chart = { - id: 'chart-1', - title: '各班级人数', - chartType: 'bar', - columns: [ - { key: 'className', title: '班级' }, - { key: 'count', title: '人数' }, - ], - rows: [ - { className: '一班', count: 20 }, - { className: '二班', count: 15 }, - ], - }; - let message = reduceAiSseMessage(undefined, { - event: 'ui.chart', - data: JSON.stringify({ messageId: 8, chart }), - }); - message = reduceAiSseMessage(message, { - event: 'ui.chart', - data: JSON.stringify({ - messageId: 8, - chart: { ...chart, id: 'chart-2', title: '女生人数' }, - }), - }); - - expect(message.charts).toHaveLength(2); - expect(message.charts?.[0]).toMatchObject({ id: 'chart-1', chartType: 'bar' }); - expect(message.charts?.[1]).toMatchObject({ id: 'chart-2' }); - }); - it('restores persisted charts from message.completed metadata', () => { const message = reduceAiSseMessage(undefined, { event: 'message.completed', @@ -459,15 +360,16 @@ describe('AI chat SSE message reducer', () => { } }); - it('routes submit-time ui.review to the original message instead of the streaming one', () => { + it('routes ui.artifact targeting another message to the external handler', () => { const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); - const onExternalReview = vi.fn(); - provider.onExternalReview = onExternalReview; - const review = { - id: 'review-1', - title: '批量导入', + const onExternalArtifact = vi.fn(); + provider.onExternalArtifact = onExternalArtifact; + const artifact = { + id: 'artifact-1', + type: 'form', status: 'submitted', - sections: [], + messageId: 12, + payload: { id: 'form-1', title: '批量导入', status: 'submitted' }, }; const origin = { id: 13, @@ -476,31 +378,37 @@ describe('AI chat SSE message reducer', () => { reasoningContent: '', toolRuns: [], attachments: [], - reviews: [], + uiArtifacts: [], }; const next = provider.transformMessage({ originMessage: origin, - chunk: { event: 'ui.review', data: JSON.stringify({ messageId: 12, review }) }, + chunk: { event: 'ui.artifact', data: JSON.stringify({ messageId: 12, artifact }) }, status: 'updating', chunks: [], responseHeaders: {} as Headers, }); - expect(onExternalReview).toHaveBeenCalledWith(12, review); + expect(onExternalArtifact).toHaveBeenCalledWith(12, artifact); expect(next).toBe(origin); - expect(next.reviews ?? []).toHaveLength(0); + expect(next.uiArtifacts ?? []).toHaveLength(0); }); - it('routes ui.review without an origin message to the external handler', () => { + it('routes ui.artifact without an origin message to the external handler', () => { const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); - const onExternalReview = vi.fn(); - provider.onExternalReview = onExternalReview; + const onExternalArtifact = vi.fn(); + provider.onExternalArtifact = onExternalArtifact; const next = provider.transformMessage({ chunk: { - event: 'ui.review', + event: 'ui.artifact', data: JSON.stringify({ messageId: 12, - review: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] }, + artifact: { + id: 'artifact-1', + type: 'review', + status: 'submitted', + messageId: 12, + payload: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] }, + }, }), }, status: 'updating', @@ -508,11 +416,11 @@ describe('AI chat SSE message reducer', () => { responseHeaders: {} as Headers, }); - expect(onExternalReview).toHaveBeenCalledWith( + expect(onExternalArtifact).toHaveBeenCalledWith( 12, - expect.objectContaining({ id: 'review-1' }), + expect.objectContaining({ id: 'artifact-1' }), ); - expect(next.reviews ?? []).toHaveLength(0); + expect(next.uiArtifacts ?? []).toHaveLength(0); }); it('tolerates non-JSON event data', () => { diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index 69b22f6d..024dfcce 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -80,6 +80,8 @@ export async function authenticatedFetch( } const response = await fetch(requestInput, { ...requestInit, headers }); if (response.status === 401) { + // 提示由登录页读取展示:直接弹 toast 会被跳转销毁 + sessionStorage.setItem('login_expired_hint', '1'); useUserStore.getState().logout(); usePermissionStore.getState().clearPermissions(); window.location.href = '/login'; @@ -189,32 +191,6 @@ export class GongxueAiChatProvider extends AbstractChatProvider< this.onExternalArtifact?.(payload.messageId, payload.artifact); return info.originMessage ?? emptyAssistant(); } - if ( - event === 'ui.form' && - payload.form && - typeof payload.messageId === 'number' && - info.originMessage?.id !== payload.messageId - ) { - this.onExternalArtifact?.(payload.messageId, { - id: payload.form.id, - type: 'form', - status: payload.form.status ?? 'pending', - messageId: payload.messageId, - payload: payload.form, - }); - return info.originMessage ?? emptyAssistant(); - } - if ( - event === 'ui.review' && - payload.review && - typeof payload.messageId === 'number' && - info.originMessage?.id !== payload.messageId - ) { - // The submitted review belongs to the original assistant message; - // do not merge it into the message currently being streamed. - this.onExternalReview?.(payload.messageId, payload.review); - return info.originMessage ?? emptyAssistant(); - } return reduceAiSseMessage(info.originMessage, info.chunk); } } diff --git a/apps/admin/src/components/AiChat/sseReducer.ts b/apps/admin/src/components/AiChat/sseReducer.ts index e58a224d..57d65fc3 100644 --- a/apps/admin/src/components/AiChat/sseReducer.ts +++ b/apps/admin/src/components/AiChat/sseReducer.ts @@ -9,7 +9,7 @@ import type { AiSseChunk, AiToolRun, } from './types'; -import { mergeArtifactIntoMessage, mergeById, mergeForms } from './uiArtifacts'; +import { mergeArtifactIntoMessage, mergeById } from './uiArtifacts'; export interface AiSsePayload { messageId?: number; @@ -25,10 +25,7 @@ export interface AiSsePayload { summary?: string | null; durationMs?: number | null; attachment?: AiAttachment; - form?: AiFormSchema; artifact?: AiArtifactSchema; - review?: AiReviewSchema; - chart?: AiChartSchema; wizard?: unknown; retry?: AiModelRetryInfo; message?: @@ -109,20 +106,21 @@ function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRu function applyMessagePayload( message: AiChatMessage, nested: AiSsePayload['message'], - payload: AiSsePayload, ): void { if (typeof nested !== 'object' || nested === null) return; - message.forms = mergeForms( + // 历史消息兼容:老数据只有 metadata.a2uiForm/a2uiReview/a2uiChart, + // 恢复为 legacy 字段供渲染层在 uiArtifacts 为空时回退使用。 + message.forms = mergeById( message.forms, - (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, + nested.metadata?.a2uiForm as AiFormSchema | undefined, ); message.reviews = mergeById( message.reviews, - (nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, + nested.metadata?.a2uiReview as AiReviewSchema | undefined, ); message.charts = mergeById( message.charts, - (nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, + nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined, ); const artifacts = nested.metadata?.uiArtifacts; if (Array.isArray(artifacts)) { @@ -150,7 +148,7 @@ export function reduceAiSseMessage( message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); + applyMessagePayload(message, nested); } else if (event === 'reasoning.delta') { message.retrying = null; message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; @@ -159,13 +157,8 @@ export function reduceAiSseMessage( message.content += payload.delta ?? payload.content ?? ''; } else if (event === 'model.retrying' && payload.retry) { message.retrying = payload.retry; - } else if (event === 'ui.form' && payload.form) { - message.forms = mergeForms(message.forms, payload.form); - } else if (event === 'ui.review' && payload.review) { - message.reviews = mergeById(message.reviews, payload.review); - } else if (event === 'ui.chart' && payload.chart) { - message.charts = mergeById(message.charts, payload.chart); } else if (event === 'ui.artifact' && payload.artifact) { + // 统一 artifact 事件;legacy 列表由渲染层从 uiArtifacts 派生。 mergeArtifactIntoMessage(message, payload.artifact); } else if (event === 'ui.import_wizard' && payload.wizard) { message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; @@ -187,7 +180,7 @@ export function reduceAiSseMessage( nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); + applyMessagePayload(message, nested); message.retrying = null; } else if (event === 'message.cancelled') { message.id = payload.messageId ?? message.id; diff --git a/apps/admin/src/components/AiChat/style.css b/apps/admin/src/components/AiChat/style.css index ab1ac75b..14ae4c30 100644 --- a/apps/admin/src/components/AiChat/style.css +++ b/apps/admin/src/components/AiChat/style.css @@ -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; diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index aee08233..85d915ce 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -171,9 +171,13 @@ export interface AiChatMessage { reasoningContent: string; toolRuns: AiToolRun[]; attachments: AiAttachment[]; + /** @deprecated 仅历史消息兼容读取(metadata.a2uiForm);新数据统一走 uiArtifacts */ forms?: AiFormSchema[]; + /** @deprecated 仅历史消息兼容读取(metadata.a2uiReview);新数据统一走 uiArtifacts */ reviews?: AiReviewSchema[]; + /** @deprecated 仅历史消息兼容读取(metadata.a2uiChart);新数据统一走 uiArtifacts */ charts?: AiChartSchema[]; + /** 统一 A2UI 制品协议(唯一事实源) */ uiArtifacts?: AiArtifactSchema[]; replyToMessageId?: number | null; metadata?: Record | null; diff --git a/apps/admin/src/components/AiChat/uiArtifacts.ts b/apps/admin/src/components/AiChat/uiArtifacts.ts index 32f1f59f..1e9b3023 100644 --- a/apps/admin/src/components/AiChat/uiArtifacts.ts +++ b/apps/admin/src/components/AiChat/uiArtifacts.ts @@ -25,43 +25,44 @@ export function mergeById( return next; } -export function mergeForms( - current: AiFormSchema[] | undefined, - incoming: AiFormSchema | AiFormSchema[] | undefined, -): AiFormSchema[] { - const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; - if (!items.length) return current ?? []; - const next = [...(current ?? [])]; - for (const item of items) { - if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) { - next.push(item); - } - } - return next; -} - -function payloadOf(artifact: AiArtifactSchema): unknown { - return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {}; -} - /** - * 将统一 artifact 归入 uiArtifacts,并按类型派发到 legacy 列表。 - * payload 来自服务端契约(表单/审阅/图表/预检/向导),按类型做单次断言。 + * 将统一 artifact 归入 uiArtifacts。 + * + * 注意:不再派发到 legacy 列表(forms/reviews/charts)——渲染层从 + * uiArtifacts 派生,legacy 字段仅保留给历史消息(metadata 中只有 + * a2uiForm/a2uiReview/a2uiChart 的老数据)作兼容读取。 */ export function mergeArtifactIntoMessage( message: AiChatMessage, artifact: AiArtifactSchema, ): AiChatMessage { message.uiArtifacts = mergeById(message.uiArtifacts, artifact); - const payload = payloadOf(artifact); - if (artifact.type === 'form') { - message.forms = mergeForms(message.forms, payload as AiFormSchema); - } else if (artifact.type === 'review') { - message.reviews = mergeById(message.reviews, payload as AiReviewSchema); - } else if (artifact.type === 'chart') { - message.charts = mergeById(message.charts, payload as AiChartSchema); - } else if (artifact.type === 'import_wizard') { - message.metadata = { ...message.metadata, a2uiImportWizard: payload }; - } return message; } + +/** + * 从 uiArtifacts 派生 legacy 列表(渲染用)。 + * 仅当 message 上没有显式 legacy 数据(历史消息)时,渲染层回退到 message.forms 等。 + */ +export function deriveForms(message: AiChatMessage): AiFormSchema[] { + return (message.uiArtifacts ?? []) + .filter((artifact) => artifact.type === 'form') + .map((artifact) => artifact.payload) + .filter((payload): payload is AiFormSchema => Boolean(payload) && typeof payload === 'object'); +} + +export function deriveReviews(message: AiChatMessage): AiReviewSchema[] { + return (message.uiArtifacts ?? []) + .filter((artifact) => artifact.type === 'review') + .map((artifact) => artifact.payload) + .filter( + (payload): payload is AiReviewSchema => Boolean(payload) && typeof payload === 'object', + ); +} + +export function deriveCharts(message: AiChatMessage): AiChartSchema[] { + return (message.uiArtifacts ?? []) + .filter((artifact) => artifact.type === 'chart') + .map((artifact) => artifact.payload) + .filter((payload): payload is AiChartSchema => Boolean(payload) && typeof payload === 'object'); +} diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index 4d7bdeaa..65f03bcc 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -296,30 +296,46 @@ export function useAiChatMessageActions({ setEditingMessageId(null); if (content === messageInfo.message.content) return; - setMessage(messageInfo.id, (info) => ({ - message: { - ...info.message, - content, - metadata: { ...info.message.metadata, edited: true }, - }, - })); + // 编辑旧消息会删除其后的全部消息并重新生成,需先告知用户 const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id); - if (index >= 0) { - for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id); + const followingCount = index >= 0 ? messagesRef.current.length - index - 1 : 0; + const doEdit = () => { + setMessage(messageInfo.id, (info) => ({ + message: { + ...info.message, + content, + metadata: { ...info.message.metadata, edited: true }, + }, + })); + if (index >= 0) { + for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id); + } + requestWithStatus({ + message: content, + attachmentIds: [], + skillKey: activeConversation?.lockedSkillKey ?? null, + clientRequestId: crypto.randomUUID(), + reasoningEffort: deepThinking ? 'high' : null, + editMessageId: messageId, + }); + }; + if (followingCount > 0) { + modal.confirm({ + title: '编辑消息将删除后续内容', + content: `编辑这条消息会删除其后的 ${followingCount} 条消息并重新生成回答,此操作不可恢复。`, + okText: '继续编辑', + cancelText: '取消', + onOk: doEdit, + }); + return; } - requestWithStatus({ - message: content, - attachmentIds: [], - skillKey: activeConversation?.lockedSkillKey ?? null, - clientRequestId: crypto.randomUUID(), - reasoningEffort: deepThinking ? 'high' : null, - editMessageId: messageId, - }); + doEdit(); }, [ activeConversation?.lockedSkillKey, activeId, deepThinking, + modal, removeMessage, requestWithStatus, setMessage, @@ -327,8 +343,9 @@ export function useAiChatMessageActions({ ); const submitForm = useCallback( - (form: AiFormSchema, values: Record) => { - if (!activeId || isRequesting) return; + async (form: AiFormSchema, values: Record): Promise => { + if (!activeId) throw new Error('当前会话不可用,请稍后重试'); + if (isRequesting) throw new Error('请等待当前 AI 回复完成后再提交表单'); requestWithStatus({ message: '表单提交', attachmentIds: [], @@ -342,8 +359,9 @@ export function useAiChatMessageActions({ ); const submitReview = useCallback( - (reviewId: string, reviewTitle?: string) => { - if (!activeId || isRequesting) return; + async (reviewId: string, reviewTitle?: string): Promise => { + if (!activeId) throw new Error('当前会话不可用,请稍后重试'); + if (isRequesting) throw new Error('请等待当前 AI 回复完成后再确认导入'); requestWithStatus({ message: '确认批量导入', attachmentIds: [], @@ -424,7 +442,9 @@ export function useAiChatMessageActions({ return; } try { - const uploaded = await aiChatApi.uploadAttachment(file); + const uploaded = await aiChatApi.uploadAttachment(file, (percent) => { + options.onProgress?.({ percent }); + }); setAttachments((items) => [...items, uploaded]); options.onSuccess?.(uploaded, file); } catch (error) { diff --git a/apps/admin/src/components/AiChat/useSubmissionState.test.tsx b/apps/admin/src/components/AiChat/useSubmissionState.test.tsx new file mode 100644 index 00000000..4fc8a422 --- /dev/null +++ b/apps/admin/src/components/AiChat/useSubmissionState.test.tsx @@ -0,0 +1,161 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useSubmissionState, useXCardSurface } from './useSubmissionState'; + +// 项目未安装 @testing-library/react,用 createRoot + harness 组件暴露 hook API +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; +let api: ReturnType | null = null; +let surface: ReturnType | null = null; +let surfaceId = 'surface-test'; + +function Harness() { + api = useSubmissionState(); + surface = useXCardSurface(surfaceId); + return null; +} + +function renderHarness(): void { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render(); + }); +} + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; + api = null; + surface = null; + surfaceId = 'surface-test'; +}); + +describe('useSubmissionState', () => { + it('tracks submitting during the task and succeeds afterwards', async () => { + renderHarness(); + let resolveTask: () => void = () => undefined; + const task = () => + new Promise((resolve) => { + resolveTask = resolve; + }); + + let promise: Promise | undefined; + act(() => { + promise = api?.run(task); + }); + expect(api?.submitting).toBe(true); + expect(api?.error).toBeNull(); + + await act(async () => { + resolveTask(); + await promise; + }); + expect(api?.submitting).toBe(false); + expect(api?.submitted).toBe(true); + expect(api?.error).toBeNull(); + }); + + it('captures the error message and keeps submitted false on failure', async () => { + renderHarness(); + const failing = () => { + throw new Error('接口 500'); + }; + + await act(async () => { + await api?.run(failing); + }); + expect(api?.submitting).toBe(false); + expect(api?.submitted).toBe(false); + expect(api?.error).toBe('接口 500'); + }); + + it('normalizes non-Error rejections to a generic message', async () => { + renderHarness(); + const failing = () => Promise.reject('raw string'); + + await act(async () => { + await api?.run(failing); + }); + expect(api?.error).toBe('提交失败,请稍后重试'); + }); + + it('ignores re-entrant calls while a task is in flight', async () => { + renderHarness(); + let resolveTask: () => void = () => undefined; + const task = () => + new Promise((resolve) => { + resolveTask = resolve; + }); + let secondRan = false; + + act(() => { + void api?.run(task); + void api?.run(() => { + secondRan = true; + }); + }); + expect(secondRan).toBe(false); + + await act(async () => { + resolveTask(); + }); + expect(api?.submitted).toBe(true); + }); + + it('reset clears submitted and error states', async () => { + renderHarness(); + await act(async () => { + await api?.run(() => undefined); + }); + expect(api?.submitted).toBe(true); + + act(() => { + api?.reset(); + }); + expect(api?.submitted).toBe(false); + expect(api?.error).toBeNull(); + }); +}); + +describe('useXCardSurface', () => { + it('deduplicates createSurface commands for the same surface id', () => { + renderHarness(); + act(() => { + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } }, + { version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 1 } }, + ]); + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } }, + { version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 2 } }, + ]); + }); + const createCommands = surface?.commands.filter((command) => 'createSurface' in command); + expect(createCommands).toHaveLength(1); + expect(surface?.commands).toHaveLength(3); + }); + + it('resets the command stream when the surface id changes', () => { + renderHarness(); + act(() => { + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'c' } }, + ]); + }); + expect(surface?.commands).toHaveLength(1); + + surfaceId = 'surface-other'; + act(() => { + root?.render(); + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-other', catalogId: 'c' } }, + ]); + }); + expect(surface?.commands).toHaveLength(1); + }); +}); diff --git a/apps/admin/src/components/AiChat/useSubmissionState.ts b/apps/admin/src/components/AiChat/useSubmissionState.ts new file mode 100644 index 00000000..c3333b9c --- /dev/null +++ b/apps/admin/src/components/AiChat/useSubmissionState.ts @@ -0,0 +1,89 @@ +import { useCallback, useRef, useState } from 'react'; +import { useLayoutEffect } 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(null); + const submittingRef = useRef(false); + + const run = useCallback(async (task: () => Promise | 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([]); + const [commands, setCommands] = useState([]); + const idRef = useRef(''); + + 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; + // 渲染期保持纯函数:ref 变更放到 layout effect 里 + useLayoutEffect(() => { + if (idRef.current !== surfaceKey) { + // 组件复用到新 surface 时,清空历史命令重新初始化 + commandsRef.current = []; + idRef.current = surfaceKey; + } + }, [surfaceKey]); + + return { commands, pushCommands }; +} diff --git a/apps/admin/src/components/AiChat/welcomeCopy.ts b/apps/admin/src/components/AiChat/welcomeCopy.ts new file mode 100644 index 00000000..1fbee621 --- /dev/null +++ b/apps/admin/src/components/AiChat/welcomeCopy.ts @@ -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; +} diff --git a/apps/admin/src/components/AppErrorBoundary.tsx b/apps/admin/src/components/AppErrorBoundary.tsx new file mode 100644 index 00000000..375056b0 --- /dev/null +++ b/apps/admin/src/components/AppErrorBoundary.tsx @@ -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 ( + window.location.reload()}> + 刷新页面 + + } + /> + ); + } + return this.props.children; + } +} + +export default AppErrorBoundary; diff --git a/apps/admin/src/components/BackTop.tsx b/apps/admin/src/components/BackTop.tsx new file mode 100644 index 00000000..43605234 --- /dev/null +++ b/apps/admin/src/components/BackTop.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; +import { Button, Tooltip } from 'antd'; +import { VerticalAlignTopOutlined } from '@ant-design/icons'; +import { useEventCallback, useEventListener } from 'usehooks-ts'; + +/** + * 全局「回到顶部」浮动按钮:长列表滚动超过 400px 后出现。 + * 尊重系统「减少动态效果」偏好,平滑滚动仅在未开启该偏好时使用。 + */ +export const BackTop: React.FC<{ threshold?: number }> = ({ threshold = 400 }) => { + const [visible, setVisible] = useState(false); + const updateVisible = useEventCallback(() => setVisible(window.scrollY > threshold)); + + useEffect(() => { + updateVisible(); + }, [threshold, updateVisible]); + + useEventListener('scroll', updateVisible, undefined, { passive: true }); + + if (!visible) return null; + + const scrollToTop = () => { + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' }); + }; + + return ( + + + ) : null} + + )}
); diff --git a/apps/admin/src/components/EditableCell/style.css b/apps/admin/src/components/EditableCell/style.css index 9e558230..2d7aedae 100644 --- a/apps/admin/src/components/EditableCell/style.css +++ b/apps/admin/src/components/EditableCell/style.css @@ -5,6 +5,29 @@ align-items: center; } +.editable-cell-display { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + width: 100%; +} + +.editable-cell-undo { + flex: none; + border: none; + background: transparent; + color: #1677ff; + font-size: 12px; + padding: 0; + cursor: pointer; + white-space: nowrap; +} + +.editable-cell-undo:hover { + text-decoration: underline; +} + .editable-cell--enabled { cursor: cell; touch-action: manipulation; diff --git a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx index 223c8aca..0515bf77 100644 --- a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx +++ b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx @@ -14,6 +14,7 @@ import { Descriptions, Flex, Modal, + Progress, Select, Space, Spin, @@ -34,11 +35,13 @@ import { importErrorReportUrl, previewImportStep, } from '../../api/imports'; +import { saveAs } from 'file-saver'; import { STEP_FIELDS, type ImportPreviewResult, type ImportReceipt, type ImportRunDetail, + type ImportStageRequest, type ImportStepKey, } from './types'; @@ -99,12 +102,7 @@ async function downloadErrorReport(runId: string, stepKey?: ImportStepKey): Prom }); if (!response.ok) throw new Error('错误报告下载失败'); const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = `导入错误报告-${runId.slice(0, 8)}.csv`; - anchor.click(); - window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + saveAs(blob, `导入错误报告-${runId.slice(0, 8)}.csv`); } export const ImportWizardModal: React.FC = ({ @@ -115,6 +113,7 @@ export const ImportWizardModal: React.FC = ({ const [run, setRun] = useState(null); const [loadingRun, setLoadingRun] = useState(false); const [uploading, setUploading] = useState(false); + const [uploadPercent, setUploadPercent] = useState(0); const [activeStepKey, setActiveStepKey] = useState(null); const [sheetSelection, setSheetSelection] = useState>({}); const [mappingDraft, setMappingDraft] = useState>>({}); @@ -185,20 +184,41 @@ export const ImportWizardModal: React.FC = ({ return [...headers]; }, [run, activeStepKey, sheetSelection]); - const handleUpload: UploadProps['customRequest'] = async (options) => { - const file = options.file as File; + /** 创建导入任务并加载详情:统一处理上传进度与 loading 状态。成功返回 run 详情,失败返回 null */ + const uploadRun = async ( + file: File, + options: { + source: 'ai' | 'manual'; + conversationId?: number; + stages?: ImportStageRequest[]; + mapping?: Record>; + }, + errorMessage: string, + ): Promise => { setUploading(true); + setUploadPercent(0); try { - const detail = await createImportRun(file, { source: 'manual' }); + const detail = await createImportRun(file, { + ...options, + onProgress: (percent) => setUploadPercent(percent), + }); await loadRun(detail.id); - message.success(`已识别 ${detail.sheets.length} 个工作表`); + return detail; } catch (error) { - message.error(error instanceof Error ? error.message : '文件上传失败'); + message.error(error instanceof Error ? error.message : errorMessage); + return null; } finally { setUploading(false); + setUploadPercent(0); } }; + const handleUpload: UploadProps['customRequest'] = async (options) => { + const file = options.file as File; + const detail = await uploadRun(file, { source: 'manual' }, '文件上传失败'); + if (detail) message.success(`已识别 ${detail.sheets.length} 个工作表`); + }; + const handlePreview = async () => { if (!run || !activeStepKey || !activeStep) return; const mapping = mappingDraft[activeStepKey] ?? {}; @@ -255,20 +275,16 @@ export const ImportWizardModal: React.FC = ({ const handleReupload = async (file: File) => { if (!run || !activeStepKey) return; - setUploading(true); - try { - const detail = await createImportRun(file, { + const detail = await uploadRun( + file, + { source: 'manual', stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }], mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} }, - }); - await loadRun(detail.id); - message.success('已重新上传,并保留原列映射'); - } catch (error) { - message.error(error instanceof Error ? error.message : '重新上传失败'); - } finally { - setUploading(false); - } + }, + '重新上传失败', + ); + if (detail) message.success('已重新上传,并保留原列映射'); }; const previewRows = useMemo(() => { @@ -405,6 +421,20 @@ export const ImportWizardModal: React.FC = ({

点击或拖拽 .xlsx / .csv 文件到此区域

单文件不超过 10MB;.xls 请先另存为 .xlsx

+ {uploading ? ( + + 0 && uploadPercent < 100 ? 'active' : 'normal'} + /> + + {uploadPercent > 0 && uploadPercent < 100 + ? `正在上传 ${uploadPercent}%...` + : '正在上传并解析文件...'} + + + ) : null} ) : ( diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index c06fbe35..78cf4861 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -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 = ({ 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'); @@ -145,6 +146,10 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`); onApplied(); reset(); + } else { + // 接口返回 success:false 时也要结束「处理中」并给出错误提示 + message.error(res.log?.message || '处理失败,请检查后重试'); + setStep('match'); } } catch (e: unknown) { const err = e as { message?: string }; @@ -168,6 +173,22 @@ const JinshujuMatchModal: React.FC = ({ 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 +351,7 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie onCancel={handleClose} width={step === 'match' || step === 'applying' ? 900 : 640} mask={{ closable: false }} + closable={step !== 'applying'} footer={ step === 'connection' ? [ @@ -380,7 +402,12 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie {step === 'rule' ? renderRuleStep() : null} {step === 'match' ? renderMatchStep() : null} {step === 'applying' ? ( - + <> + + + 数据正在写入,关闭窗口不会中断同步 + + ) : null} ); diff --git a/apps/admin/src/components/NextStepHint.tsx b/apps/admin/src/components/NextStepHint.tsx new file mode 100644 index 00000000..d9b4a571 --- /dev/null +++ b/apps/admin/src/components/NextStepHint.tsx @@ -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 = ({ + title, + description, + action, + closable = true, + onClose, +}) => { + const [dismissed, setDismissed] = useState(false); + if (dismissed) return null; + + return ( + + + + + {title} + {description ? {description} : null} + + + {action ? ( + + ) : null} + {closable ? ( +
@@ -199,7 +201,12 @@ const NotificationBell: React.FC = () => { placement="bottomRight" > - + + ) : null} + + ); +}; + +export default QueryEmpty; diff --git a/apps/admin/src/components/QueryState/QueryErrorState.tsx b/apps/admin/src/components/QueryState/QueryErrorState.tsx new file mode 100644 index 00000000..9056d1e7 --- /dev/null +++ b/apps/admin/src/components/QueryState/QueryErrorState.tsx @@ -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 = ({ + title = '数据加载失败', + description = '请检查网络后重试。', + onRetry, + compact = false, +}) => { + if (compact) { + return ( +
+ {title} + {description ? ( +
+ + {description} + +
+ ) : null} + {onRetry ? ( +
+ +
+ ) : null} +
+ ); + } + return ( + 重试 : undefined} + /> + ); +}; + +export default QueryErrorState; diff --git a/apps/admin/src/components/QueryState/index.ts b/apps/admin/src/components/QueryState/index.ts new file mode 100644 index 00000000..32276568 --- /dev/null +++ b/apps/admin/src/components/QueryState/index.ts @@ -0,0 +1,4 @@ +export { QueryErrorState } from './QueryErrorState'; +export type { QueryErrorStateProps } from './QueryErrorState'; +export { QueryEmpty } from './QueryEmpty'; +export type { QueryEmptyProps, QueryEmptyAction } from './QueryEmpty'; diff --git a/apps/admin/src/components/RefreshButton.tsx b/apps/admin/src/components/RefreshButton.tsx new file mode 100644 index 00000000..bf3e97ca --- /dev/null +++ b/apps/admin/src/components/RefreshButton.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Button, Tooltip } from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; + +interface RefreshButtonProps { + onRefresh: () => void; + loading?: boolean; +} + +/** 列表工具栏刷新入口:手动重新拉取当前数据,带加载反馈。 */ +export const RefreshButton: React.FC = ({ onRefresh, loading }) => ( + + + + ) : undefined + } /> ); diff --git a/apps/admin/src/components/RouteKeeper.tsx b/apps/admin/src/components/RouteKeeper.tsx index a07a8b7d..4c1de962 100644 --- a/apps/admin/src/components/RouteKeeper.tsx +++ b/apps/admin/src/components/RouteKeeper.tsx @@ -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 ( - <> + {Array.from(cacheRef.current.entries()).map(([key, node]) => (
- {node} + {node}
))} - +
); }; diff --git a/apps/admin/src/components/ScrollToTop.tsx b/apps/admin/src/components/ScrollToTop.tsx new file mode 100644 index 00000000..b01b388b --- /dev/null +++ b/apps/admin/src/components/ScrollToTop.tsx @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router'; + +/** + * SPA 路由切换后把滚动位置复位到顶部。 + * 只在 pathname 变化时触发,避免干扰弹窗/抽屉等局部滚动。 + */ +export const ScrollToTop: React.FC = () => { + const { pathname } = useLocation(); + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + return null; +}; + +export default ScrollToTop; diff --git a/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx b/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx index c987f780..b9ae266f 100644 --- a/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx +++ b/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { App, Button, Popconfirm, Space, Table, Upload } from 'antd'; +import React, { useRef, useState } from 'react'; +import { App, Button, Modal, Popconfirm, Space, Table, Upload } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons'; import api from '../../api'; @@ -10,6 +10,20 @@ import { getErrorMessage } from '../../utils/error'; import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared'; import type { AttachmentRecord, TabProps } from './shared'; +type AttachmentPreview = { + url: string; + name: string; + kind: 'image' | 'pdf'; +}; + +/** 根据文件扩展名决定安全展示方式:图片/PDF 内联预览,其余一律下载 */ +function getAttachmentKind(fileName: string, mimeType?: string): 'image' | 'pdf' | 'download' { + const ext = fileName.split('.').pop()?.toLowerCase() ?? ''; + if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'].includes(ext)) return 'image'; + if (ext === 'pdf' || mimeType === 'application/pdf') return 'pdf'; + return 'download'; +} + export const AttachmentsTab: React.FC = ({ data, studentId, @@ -18,6 +32,9 @@ export const AttachmentsTab: React.FC = const { hasPermission } = usePermission(); const canPurgeArchive = hasPermission('archive:purge'); const [uploading, setUploading] = useState(false); + const [preview, setPreview] = useState(null); + // 预览请求序号:快速点不同行「查看」时,慢的旧响应回来直接丢弃,避免覆盖新预览 + const previewSeqRef = useRef(0); const deleteAttachmentMutation = useApiMutation( async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`), @@ -33,6 +50,39 @@ export const AttachmentsTab: React.FC = { invalidate: [['archive', studentId]] }, ); + const closePreview = () => { + previewSeqRef.current += 1; // 关闭后仍在途的旧响应也不再落地 + if (preview?.url) URL.revokeObjectURL(preview.url); + setPreview(null); + }; + + const openAttachment = async (record: AttachmentRecord) => { + const seq = ++previewSeqRef.current; + try { + const blob = await api.get(`/archive/${studentId}/attachments/${record.id}`, { + responseType: 'blob', + }); + if (seq !== previewSeqRef.current) return; // 已有更新的查看请求,丢弃本次慢响应 + const kind = getAttachmentKind(record.fileName, record.mimeType); + const url = URL.createObjectURL(blob); + if (kind === 'download') { + // 非内联类型通过 download 属性触发下载,避免以页面同源打开可执行内容 + const a = document.createElement('a'); + a.href = url; + a.download = record.fileName || 'attachment'; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + } else { + if (preview?.url) URL.revokeObjectURL(preview.url); + setPreview({ url, name: record.fileName || 'attachment', kind }); + } + } catch (e: unknown) { + message.error(getErrorMessage(e, '查看失败')); + } + }; + const handleDelete = async (attachmentId: number) => { try { await deleteAttachmentMutation.mutateAsync(attachmentId); @@ -72,22 +122,7 @@ export const AttachmentsTab: React.FC = title: '操作', render: (_: unknown, record: AttachmentRecord) => ( - {hasPermission('student:edit') && record.status !== 'archived' ? ( @@ -137,7 +172,7 @@ export const AttachmentsTab: React.FC = ) : null} - + scroll={{ x: 'max-content' }} columns={columns} dataSource={data} rowKey="id" @@ -149,6 +184,25 @@ export const AttachmentsTab: React.FC = }} style={{ marginTop: 16 }} /> + + {preview?.kind === 'image' ? ( + {preview.name} + ) : preview?.kind === 'pdf' ? ( +