From 8dc72d6e1b1bf162e63772c8650379dcc516174f Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 17:38:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E7=BC=96=E8=BE=91=E6=B6=88?= =?UTF-8?q?=E6=81=AF/=E5=88=87=E6=8D=A2=E4=BC=9A=E8=AF=9D=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E3=80=81=E8=A1=8C=E5=86=85=E7=BC=96=E8=BE=91=E6=92=A4?= =?UTF-8?q?=E9=94=80=E4=B8=8E=E4=B8=8A=E4=BC=A0=E8=BF=9B=E5=BA=A6=E5=8F=8D?= =?UTF-8?q?=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 编辑旧消息会删除其后全部消息时,先弹确认告知影响范围 - 有待发送附件时切换会话,先确认再丢弃(避免静默删除已上传文件) - EditableCell 保存成功后提供 6 秒内"撤销"入口(把旧值重新保存), 重新编辑时自动清除 - 导入向导/重新上传显示上传进度条与百分比,不再"假死" - AI 附件上传透传进度给附件列表 - 租赁合同上传显示按钮 loading 与上传百分比 --- apps/admin/src/api/imports.ts | 9 +++- .../src/components/AiChat/AiChatDrawer.tsx | 29 ++++++++-- apps/admin/src/components/AiChat/api.ts | 9 +++- .../AiChat/useAiChatMessageActions.tsx | 54 ++++++++++++------- .../src/components/EditableCell/index.tsx | 52 +++++++++++++++++- .../src/components/EditableCell/style.css | 23 ++++++++ .../ImportWizard/ImportWizardModal.tsx | 26 ++++++++- .../pages/ClassroomRentals/RentalTable.tsx | 28 ++++++++-- .../src/pages/ClassroomRentals/index.tsx | 25 +++++++-- 9 files changed, 220 insertions(+), 35 deletions(-) diff --git a/apps/admin/src/api/imports.ts b/apps/admin/src/api/imports.ts index 635e8c2..e691237 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/components/AiChat/AiChatDrawer.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.tsx index 86c3d99..1ba62d9 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.tsx @@ -189,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( diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts index bcbd8c3..28e5fc4 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/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index 043032c..65f03bc 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, @@ -426,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/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx index a37a31b..8e63384 100644 --- a/apps/admin/src/components/EditableCell/index.tsx +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -102,8 +102,18 @@ const EditableCell = ({ const [draft, setDraft] = useState(() => normalizeEditableValue(formatValue ? formatValue(value) : value, editor), ); + // 保存成功后短暂显示「撤销」入口:记录保存前的序列化旧值 + const [undoMeta, setUndoMeta] = useState<{ serializedPrevious: unknown } | null>(null); + const undoTimerRef = useRef(undefined); const enabled = !disabled && (!permission || hasPermission(permission)); + useEffect( + () => () => { + window.clearTimeout(undoTimerRef.current); + }, + [], + ); + const original = useMemo( () => serializeEditableValue( @@ -133,10 +143,15 @@ const EditableCell = ({ return true; } setSaving(true); + const previousValue = original; try { await onSave(parseValue ? parseValue(serialized) : (serialized as Value)); if (activeCell?.id === idRef.current) activeCell = null; setEditing(false); + // 提供 6 秒内的撤销入口(把旧值再保存一次) + setUndoMeta({ serializedPrevious: previousValue }); + window.clearTimeout(undoTimerRef.current); + undoTimerRef.current = window.setTimeout(() => setUndoMeta(null), 6_000); return true; } catch (error) { message.error(getErrorMessage(error, '保存失败')); @@ -197,10 +212,29 @@ const EditableCell = ({ if (!saved) return; } activeCell = { id: idRef.current, save }; + // 重新进入编辑时清掉上一次的撤销入口 + window.clearTimeout(undoTimerRef.current); + setUndoMeta(null); setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); setEditing(true); }; + const handleUndo = async () => { + if (!undoMeta) return; + window.clearTimeout(undoTimerRef.current); + setUndoMeta(null); + try { + await onSave( + parseValue + ? parseValue(undoMeta.serializedPrevious) + : (undoMeta.serializedPrevious as Value), + ); + message.success('已撤销修改'); + } catch (error) { + message.error(getErrorMessage(error, '撤销失败')); + } + }; + const onPointerDown = (event: React.PointerEvent) => { if (event.pointerType !== 'touch' || editing) return; touchStartRef.current = { @@ -308,7 +342,23 @@ const EditableCell = ({ {editing ? ( {control} ) : ( - {children} + + + {children} + {undoMeta ? ( + + ) : null} + + )} ); diff --git a/apps/admin/src/components/EditableCell/style.css b/apps/admin/src/components/EditableCell/style.css index 9e55823..2d7aeda 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 223c8ac..66d8957 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, @@ -115,6 +116,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>>({}); @@ -188,14 +190,19 @@ export const ImportWizardModal: React.FC = ({ const handleUpload: UploadProps['customRequest'] = async (options) => { const file = options.file as File; setUploading(true); + setUploadPercent(0); try { - const detail = await createImportRun(file, { source: 'manual' }); + const detail = await createImportRun(file, { + source: 'manual', + onProgress: (percent) => setUploadPercent(percent), + }); await loadRun(detail.id); message.success(`已识别 ${detail.sheets.length} 个工作表`); } catch (error) { message.error(error instanceof Error ? error.message : '文件上传失败'); } finally { setUploading(false); + setUploadPercent(0); } }; @@ -256,11 +263,13 @@ export const ImportWizardModal: React.FC = ({ const handleReupload = async (file: File) => { if (!run || !activeStepKey) return; setUploading(true); + setUploadPercent(0); try { const detail = await createImportRun(file, { source: 'manual', stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }], mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} }, + onProgress: (percent) => setUploadPercent(percent), }); await loadRun(detail.id); message.success('已重新上传,并保留原列映射'); @@ -268,6 +277,7 @@ export const ImportWizardModal: React.FC = ({ message.error(error instanceof Error ? error.message : '重新上传失败'); } finally { setUploading(false); + setUploadPercent(0); } }; @@ -405,6 +415,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/pages/ClassroomRentals/RentalTable.tsx b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx index 9a58f71..c3d7dcd 100644 --- a/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx +++ b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Button, Empty, @@ -43,7 +43,11 @@ export interface RentalTableProps { onPurge: (id: number, name: string) => void; onDownloadContract: (id: number, filename?: string) => void; onDeleteContract: (id: number) => void; - onUploadContract: (id: number, formData: FormData) => Promise; + onUploadContract: ( + id: number, + formData: FormData, + onProgress?: (percent: number) => void, + ) => Promise; } export const RentalTable: React.FC = ({ @@ -62,6 +66,9 @@ export const RentalTable: React.FC = ({ onDeleteContract, onUploadContract, }) => { + const [uploadingContractId, setUploadingContractId] = useState(null); + const [contractPercent, setContractPercent] = useState(0); + const EditableRentalCell = ({ value, field, @@ -250,17 +257,28 @@ export const RentalTable: React.FC = ({ } const formData = new FormData(); formData.append('file', file); + setUploadingContractId(r.id); + setContractPercent(0); try { - await onUploadContract(r.id, formData); + await onUploadContract(r.id, formData, (percent) => setContractPercent(percent)); message.success('合同已上传'); onSuccess?.({}); } catch (e) { onError?.(e as Error); + } finally { + setUploadingContractId(null); + setContractPercent(0); } }} > - ) : ( diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index d59bd24..ba9bf1f 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -140,8 +140,21 @@ const ClassroomRentalsPage: React.FC = () => { { invalidate: [['classroom-rentals']] }, ); const uploadContractMutation = useApiMutation( - async ({ id, formData }: { id: number; formData: FormData }) => - api.post(`/classroom-rentals/${id}/contract`, formData), + async ({ + id, + formData, + onProgress, + }: { + id: number; + formData: FormData; + onProgress?: (percent: number) => void; + }) => + api.post(`/classroom-rentals/${id}/contract`, formData, { + onUploadProgress: (event) => { + if (!onProgress || !event.total) return; + onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100)); + }, + }), { invalidate: [['classroom-rentals']] }, ); @@ -319,8 +332,12 @@ const ClassroomRentalsPage: React.FC = () => { } }; - const handleUploadContract = async (id: number, formData: FormData) => { - return uploadContractMutation.mutateAsync({ id, formData }); + const handleUploadContract = async ( + id: number, + formData: FormData, + onProgress?: (percent: number) => void, + ) => { + return uploadContractMutation.mutateAsync({ id, formData, onProgress }); }; const openEdit = (record: any) => {