From a32c0a073193ef045f4915533f1432731469bd99 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 9 Aug 2026 21:29:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E5=89=8D=E7=AB=AF=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=B8=8E=E5=AE=89=E5=85=A8=E4=BF=AE=E5=A4=8D=EF=BC=88?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E5=8B=BE=E9=80=89/=E6=9C=88=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E6=96=B0=E5=BB=BA/=E9=99=84=E4=BB=B6=E9=A2=84?= =?UTF-8?q?=E8=A7=88/=E8=80=83=E5=8B=A4=20late=20=E7=AD=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - Roles 多组勾选、Schedules 月视图新建、useDirtyGuard 稳定引用 - JinshujuMatchModal success:false、Attendance store 订阅、EditableCell Enter、DynamicChart 空数据、渲染期 ref - AttachmentsTab 附件预览不再 window.open(防存储型 XSS)+ 请求乱序防护 - 考勤 late 计入出勤率与主状态 Reviewed-by: OCR (open-codereview.ai) --- .../src/components/AiChat/DynamicChart.tsx | 9 +- .../components/AiChat/useSubmissionState.ts | 14 +-- .../src/components/EditableCell/index.tsx | 10 +++ .../src/components/JinshujuMatchModal.tsx | 4 + .../StudentProfileContent/AttachmentsTab.tsx | 90 +++++++++++++++---- .../StudentProfileContent/shared.ts | 1 + apps/admin/src/hooks/useDirtyGuard.ts | 5 +- .../Attendance/AttendanceAdmin.helpers.tsx | 7 +- apps/admin/src/pages/Attendance/index.tsx | 11 +-- apps/admin/src/pages/Roles/index.tsx | 29 +++--- apps/admin/src/pages/Schedules/index.tsx | 3 +- 11 files changed, 134 insertions(+), 49 deletions(-) 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' ? ( +