diff --git a/apps/admin/src/components/AiChat/DynamicChart.tsx b/apps/admin/src/components/AiChat/DynamicChart.tsx index 06e0d0d..ee3e1f3 100644 --- a/apps/admin/src/components/AiChat/DynamicChart.tsx +++ b/apps/admin/src/components/AiChat/DynamicChart.tsx @@ -201,11 +201,16 @@ 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 (!chart.rows || chart.rows.length === 0) { + if (!hasData) { return (
diff --git a/apps/admin/src/components/AiChat/useSubmissionState.ts b/apps/admin/src/components/AiChat/useSubmissionState.ts index 60fe47e..c3333b9 100644 --- a/apps/admin/src/components/AiChat/useSubmissionState.ts +++ b/apps/admin/src/components/AiChat/useSubmissionState.ts @@ -1,4 +1,5 @@ import { useCallback, useRef, useState } from 'react'; +import { useLayoutEffect } from 'react'; import type { XAgentCommand_v0_9 } from '@ant-design/x-card'; /** @@ -75,11 +76,14 @@ export function useXCardSurface(surfaceId: string) { ); const surfaceKey = surfaceId; - if (idRef.current !== surfaceKey) { - // 组件复用到新 surface 时,清空历史命令重新初始化 - commandsRef.current = []; - idRef.current = surfaceKey; - } + // 渲染期保持纯函数: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/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx index 32bfe8c..e5d1a81 100644 --- a/apps/admin/src/components/EditableCell/index.tsx +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -263,6 +263,16 @@ const EditableCell = ({ return; } if (event.key === 'Enter' && editor !== 'textarea') { + // 这些编辑器会自己消费 Enter(确认/提交选中值),不重复触发单元格保存 + if ( + editor === 'select' || + editor === 'multi-select' || + editor === 'tags' || + editor === 'date' || + editor === 'date-range' + ) { + return; + } event.preventDefault(); await save(); return; diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index 11d228e..78cf486 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -146,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 }; diff --git a/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx b/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx index b9fbf0e..b9ae266 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' ? ( @@ -149,6 +184,25 @@ export const AttachmentsTab: React.FC = }} style={{ marginTop: 16 }} /> + + {preview?.kind === 'image' ? ( + {preview.name} + ) : preview?.kind === 'pdf' ? ( +