fix(admin): 编辑消息/切换会话保护、行内编辑撤销与上传进度反馈

- 编辑旧消息会删除其后全部消息时,先弹确认告知影响范围
- 有待发送附件时切换会话,先确认再丢弃(避免静默删除已上传文件)
- EditableCell 保存成功后提供 6 秒内"撤销"入口(把旧值重新保存),
  重新编辑时自动清除
- 导入向导/重新上传显示上传进度条与百分比,不再"假死"
- AI 附件上传透传进度给附件列表
- 租赁合同上传显示按钮 loading 与上传百分比
This commit is contained in:
2026-08-07 17:38:21 +08:00
parent 00e2bc5acf
commit 8dc72d6e1b
9 changed files with 220 additions and 35 deletions

View File

@@ -21,6 +21,8 @@ export async function createImportRun(
conversationId?: number; conversationId?: number;
stages?: ImportStageRequest[]; stages?: ImportStageRequest[];
mapping?: Record<string, Record<string, string>>; mapping?: Record<string, Record<string, string>>;
/** 上传进度回调0-100 */
onProgress?: (percent: number) => void;
}, },
): Promise<ImportRunDetail> { ): Promise<ImportRunDetail> {
const form = new FormData(); const form = new FormData();
@@ -31,7 +33,12 @@ export async function createImportRun(
if (options.mapping && Object.keys(options.mapping).length > 0) { if (options.mapping && Object.keys(options.mapping).length > 0) {
form.append('mapping', JSON.stringify(options.mapping)); form.append('mapping', JSON.stringify(options.mapping));
} }
const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form); const res = await api.post<ApiEnvelope<ImportRunDetail>>('/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; return res.data;
} }

View File

@@ -189,11 +189,32 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
const switchConversation = useCallback( const switchConversation = useCallback(
(key: string) => { (key: string) => {
discardPendingAttachments(); const doSwitch = () => {
if (isMobile) setSidebarOpen(false); discardPendingAttachments();
setActiveConversationKey(key); 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( useEffect(

View File

@@ -30,12 +30,19 @@ export const aiChatApi = {
`${basePath}/${conversationId}/messages/${messageId}`, `${basePath}/${conversationId}/messages/${messageId}`,
) )
).data, ).data,
uploadAttachment: async (file: File): Promise<AiAttachment> => { uploadAttachment: async (
file: File,
onProgress?: (percent: number) => void,
): Promise<AiAttachment> => {
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
return ( return (
await api.post<AiApiResponse<AiAttachment>>('/ai/chat/attachments', form, { await api.post<AiApiResponse<AiAttachment>>('/ai/chat/attachments', form, {
timeout: 120_000, timeout: 120_000,
onUploadProgress: (event) => {
if (!onProgress || !event.total) return;
onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100));
},
}) })
).data; ).data;
}, },

View File

@@ -296,30 +296,46 @@ export function useAiChatMessageActions({
setEditingMessageId(null); setEditingMessageId(null);
if (content === messageInfo.message.content) return; 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); const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id);
if (index >= 0) { const followingCount = index >= 0 ? messagesRef.current.length - index - 1 : 0;
for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id); 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({ doEdit();
message: content,
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
editMessageId: messageId,
});
}, },
[ [
activeConversation?.lockedSkillKey, activeConversation?.lockedSkillKey,
activeId, activeId,
deepThinking, deepThinking,
modal,
removeMessage, removeMessage,
requestWithStatus, requestWithStatus,
setMessage, setMessage,
@@ -426,7 +442,9 @@ export function useAiChatMessageActions({
return; return;
} }
try { try {
const uploaded = await aiChatApi.uploadAttachment(file); const uploaded = await aiChatApi.uploadAttachment(file, (percent) => {
options.onProgress?.({ percent });
});
setAttachments((items) => [...items, uploaded]); setAttachments((items) => [...items, uploaded]);
options.onSuccess?.(uploaded, file); options.onSuccess?.(uploaded, file);
} catch (error) { } catch (error) {

View File

@@ -102,8 +102,18 @@ const EditableCell = <Value,>({
const [draft, setDraft] = useState<unknown>(() => const [draft, setDraft] = useState<unknown>(() =>
normalizeEditableValue(formatValue ? formatValue(value) : value, editor), normalizeEditableValue(formatValue ? formatValue(value) : value, editor),
); );
// 保存成功后短暂显示「撤销」入口:记录保存前的序列化旧值
const [undoMeta, setUndoMeta] = useState<{ serializedPrevious: unknown } | null>(null);
const undoTimerRef = useRef<number | undefined>(undefined);
const enabled = !disabled && (!permission || hasPermission(permission)); const enabled = !disabled && (!permission || hasPermission(permission));
useEffect(
() => () => {
window.clearTimeout(undoTimerRef.current);
},
[],
);
const original = useMemo( const original = useMemo(
() => () =>
serializeEditableValue( serializeEditableValue(
@@ -133,10 +143,15 @@ const EditableCell = <Value,>({
return true; return true;
} }
setSaving(true); setSaving(true);
const previousValue = original;
try { try {
await onSave(parseValue ? parseValue(serialized) : (serialized as Value)); await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
if (activeCell?.id === idRef.current) activeCell = null; if (activeCell?.id === idRef.current) activeCell = null;
setEditing(false); setEditing(false);
// 提供 6 秒内的撤销入口(把旧值再保存一次)
setUndoMeta({ serializedPrevious: previousValue });
window.clearTimeout(undoTimerRef.current);
undoTimerRef.current = window.setTimeout(() => setUndoMeta(null), 6_000);
return true; return true;
} catch (error) { } catch (error) {
message.error(getErrorMessage(error, '保存失败')); message.error(getErrorMessage(error, '保存失败'));
@@ -197,10 +212,29 @@ const EditableCell = <Value,>({
if (!saved) return; if (!saved) return;
} }
activeCell = { id: idRef.current, save }; activeCell = { id: idRef.current, save };
// 重新进入编辑时清掉上一次的撤销入口
window.clearTimeout(undoTimerRef.current);
setUndoMeta(null);
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
setEditing(true); 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<HTMLDivElement>) => { const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch' || editing) return; if (event.pointerType !== 'touch' || editing) return;
touchStartRef.current = { touchStartRef.current = {
@@ -308,7 +342,23 @@ const EditableCell = <Value,>({
{editing ? ( {editing ? (
<Spin spinning={saving}>{control}</Spin> <Spin spinning={saving}>{control}</Spin>
) : ( ) : (
<Tooltip title={enabled ? '双击编辑,触屏双击编辑' : undefined}>{children}</Tooltip> <Tooltip title={enabled ? '双击编辑,触屏双击编辑' : undefined}>
<span className="editable-cell-display">
{children}
{undoMeta ? (
<button
type="button"
className="editable-cell-undo"
onClick={(event) => {
event.stopPropagation();
void handleUndo();
}}
>
</button>
) : null}
</span>
</Tooltip>
)} )}
</div> </div>
); );

View File

@@ -5,6 +5,29 @@
align-items: center; 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 { .editable-cell--enabled {
cursor: cell; cursor: cell;
touch-action: manipulation; touch-action: manipulation;

View File

@@ -14,6 +14,7 @@ import {
Descriptions, Descriptions,
Flex, Flex,
Modal, Modal,
Progress,
Select, Select,
Space, Space,
Spin, Spin,
@@ -115,6 +116,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
const [run, setRun] = useState<ImportRunDetail | null>(null); const [run, setRun] = useState<ImportRunDetail | null>(null);
const [loadingRun, setLoadingRun] = useState(false); const [loadingRun, setLoadingRun] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const [activeStepKey, setActiveStepKey] = useState<ImportStepKey | null>(null); const [activeStepKey, setActiveStepKey] = useState<ImportStepKey | null>(null);
const [sheetSelection, setSheetSelection] = useState<Record<string, string[]>>({}); const [sheetSelection, setSheetSelection] = useState<Record<string, string[]>>({});
const [mappingDraft, setMappingDraft] = useState<Record<string, Record<string, string>>>({}); const [mappingDraft, setMappingDraft] = useState<Record<string, Record<string, string>>>({});
@@ -188,14 +190,19 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
const handleUpload: UploadProps['customRequest'] = async (options) => { const handleUpload: UploadProps['customRequest'] = async (options) => {
const file = options.file as File; const file = options.file as File;
setUploading(true); setUploading(true);
setUploadPercent(0);
try { try {
const detail = await createImportRun(file, { source: 'manual' }); const detail = await createImportRun(file, {
source: 'manual',
onProgress: (percent) => setUploadPercent(percent),
});
await loadRun(detail.id); await loadRun(detail.id);
message.success(`已识别 ${detail.sheets.length} 个工作表`); message.success(`已识别 ${detail.sheets.length} 个工作表`);
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '文件上传失败'); message.error(error instanceof Error ? error.message : '文件上传失败');
} finally { } finally {
setUploading(false); setUploading(false);
setUploadPercent(0);
} }
}; };
@@ -256,11 +263,13 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
const handleReupload = async (file: File) => { const handleReupload = async (file: File) => {
if (!run || !activeStepKey) return; if (!run || !activeStepKey) return;
setUploading(true); setUploading(true);
setUploadPercent(0);
try { try {
const detail = await createImportRun(file, { const detail = await createImportRun(file, {
source: 'manual', source: 'manual',
stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }], stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }],
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} }, mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
onProgress: (percent) => setUploadPercent(percent),
}); });
await loadRun(detail.id); await loadRun(detail.id);
message.success('已重新上传,并保留原列映射'); message.success('已重新上传,并保留原列映射');
@@ -268,6 +277,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
message.error(error instanceof Error ? error.message : '重新上传失败'); message.error(error instanceof Error ? error.message : '重新上传失败');
} finally { } finally {
setUploading(false); setUploading(false);
setUploadPercent(0);
} }
}; };
@@ -405,6 +415,20 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
<p className="ant-upload-text"> .xlsx / .csv </p> <p className="ant-upload-text"> .xlsx / .csv </p>
<p className="ant-upload-hint"> 10MB.xls .xlsx</p> <p className="ant-upload-hint"> 10MB.xls .xlsx</p>
</Upload.Dragger> </Upload.Dragger>
{uploading ? (
<Flex vertical gap={4} style={{ marginTop: 8 }}>
<Progress
percent={uploadPercent}
size="small"
status={uploadPercent > 0 && uploadPercent < 100 ? 'active' : 'normal'}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, textAlign: 'center' }}>
{uploadPercent > 0 && uploadPercent < 100
? `正在上传 ${uploadPercent}%...`
: '正在上传并解析文件...'}
</Typography.Text>
</Flex>
) : null}
</Space> </Space>
) : ( ) : (
<Space orientation="vertical" size={16} style={{ width: '100%' }}> <Space orientation="vertical" size={16} style={{ width: '100%' }}>

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { import {
Button, Button,
Empty, Empty,
@@ -43,7 +43,11 @@ export interface RentalTableProps {
onPurge: (id: number, name: string) => void; onPurge: (id: number, name: string) => void;
onDownloadContract: (id: number, filename?: string) => void; onDownloadContract: (id: number, filename?: string) => void;
onDeleteContract: (id: number) => void; onDeleteContract: (id: number) => void;
onUploadContract: (id: number, formData: FormData) => Promise<unknown>; onUploadContract: (
id: number,
formData: FormData,
onProgress?: (percent: number) => void,
) => Promise<unknown>;
} }
export const RentalTable: React.FC<RentalTableProps> = ({ export const RentalTable: React.FC<RentalTableProps> = ({
@@ -62,6 +66,9 @@ export const RentalTable: React.FC<RentalTableProps> = ({
onDeleteContract, onDeleteContract,
onUploadContract, onUploadContract,
}) => { }) => {
const [uploadingContractId, setUploadingContractId] = useState<number | null>(null);
const [contractPercent, setContractPercent] = useState(0);
const EditableRentalCell = <R extends { id: number; effectiveStatus?: string }>({ const EditableRentalCell = <R extends { id: number; effectiveStatus?: string }>({
value, value,
field, field,
@@ -250,17 +257,28 @@ export const RentalTable: React.FC<RentalTableProps> = ({
} }
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
setUploadingContractId(r.id);
setContractPercent(0);
try { try {
await onUploadContract(r.id, formData); await onUploadContract(r.id, formData, (percent) => setContractPercent(percent));
message.success('合同已上传'); message.success('合同已上传');
onSuccess?.({}); onSuccess?.({});
} catch (e) { } catch (e) {
onError?.(e as Error); onError?.(e as Error);
} finally {
setUploadingContractId(null);
setContractPercent(0);
} }
}} }}
> >
<Button size="small" icon={<UploadOutlined />}> <Button
PDF size="small"
icon={<UploadOutlined />}
loading={uploadingContractId === r.id}
>
{uploadingContractId === r.id && contractPercent > 0 && contractPercent < 100
? `上传中 ${contractPercent}%`
: '上传PDF'}
</Button> </Button>
</Upload> </Upload>
) : ( ) : (

View File

@@ -140,8 +140,21 @@ const ClassroomRentalsPage: React.FC = () => {
{ invalidate: [['classroom-rentals']] }, { invalidate: [['classroom-rentals']] },
); );
const uploadContractMutation = useApiMutation( const uploadContractMutation = useApiMutation(
async ({ id, formData }: { id: number; formData: FormData }) => async ({
api.post(`/classroom-rentals/${id}/contract`, formData), 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']] }, { invalidate: [['classroom-rentals']] },
); );
@@ -319,8 +332,12 @@ const ClassroomRentalsPage: React.FC = () => {
} }
}; };
const handleUploadContract = async (id: number, formData: FormData) => { const handleUploadContract = async (
return uploadContractMutation.mutateAsync({ id, formData }); id: number,
formData: FormData,
onProgress?: (percent: number) => void,
) => {
return uploadContractMutation.mutateAsync({ id, formData, onProgress });
}; };
const openEdit = (record: any) => { const openEdit = (record: any) => {