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;
stages?: ImportStageRequest[];
mapping?: Record<string, Record<string, string>>;
/** 上传进度回调0-100 */
onProgress?: (percent: number) => void;
},
): Promise<ImportRunDetail> {
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<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;
}

View File

@@ -189,11 +189,32 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ 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(

View File

@@ -30,12 +30,19 @@ export const aiChatApi = {
`${basePath}/${conversationId}/messages/${messageId}`,
)
).data,
uploadAttachment: async (file: File): Promise<AiAttachment> => {
uploadAttachment: async (
file: File,
onProgress?: (percent: number) => void,
): Promise<AiAttachment> => {
const form = new FormData();
form.append('file', file);
return (
await api.post<AiApiResponse<AiAttachment>>('/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;
},

View File

@@ -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) {

View File

@@ -102,8 +102,18 @@ const EditableCell = <Value,>({
const [draft, setDraft] = useState<unknown>(() =>
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));
useEffect(
() => () => {
window.clearTimeout(undoTimerRef.current);
},
[],
);
const original = useMemo(
() =>
serializeEditableValue(
@@ -133,10 +143,15 @@ const EditableCell = <Value,>({
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 = <Value,>({
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<HTMLDivElement>) => {
if (event.pointerType !== 'touch' || editing) return;
touchStartRef.current = {
@@ -308,7 +342,23 @@ const EditableCell = <Value,>({
{editing ? (
<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>
);

View File

@@ -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;

View File

@@ -14,6 +14,7 @@ import {
Descriptions,
Flex,
Modal,
Progress,
Select,
Space,
Spin,
@@ -115,6 +116,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
const [run, setRun] = useState<ImportRunDetail | null>(null);
const [loadingRun, setLoadingRun] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const [activeStepKey, setActiveStepKey] = useState<ImportStepKey | null>(null);
const [sheetSelection, setSheetSelection] = useState<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 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<ImportWizardModalProps> = ({
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<ImportWizardModalProps> = ({
message.error(error instanceof Error ? error.message : '重新上传失败');
} finally {
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-hint"> 10MB.xls .xlsx</p>
</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 orientation="vertical" size={16} style={{ width: '100%' }}>

View File

@@ -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<unknown>;
onUploadContract: (
id: number,
formData: FormData,
onProgress?: (percent: number) => void,
) => Promise<unknown>;
}
export const RentalTable: React.FC<RentalTableProps> = ({
@@ -62,6 +66,9 @@ export const RentalTable: React.FC<RentalTableProps> = ({
onDeleteContract,
onUploadContract,
}) => {
const [uploadingContractId, setUploadingContractId] = useState<number | null>(null);
const [contractPercent, setContractPercent] = useState(0);
const EditableRentalCell = <R extends { id: number; effectiveStatus?: string }>({
value,
field,
@@ -250,17 +257,28 @@ export const RentalTable: React.FC<RentalTableProps> = ({
}
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);
}
}}
>
<Button size="small" icon={<UploadOutlined />}>
PDF
<Button
size="small"
icon={<UploadOutlined />}
loading={uploadingContractId === r.id}
>
{uploadingContractId === r.id && contractPercent > 0 && contractPercent < 100
? `上传中 ${contractPercent}%`
: '上传PDF'}
</Button>
</Upload>
) : (

View File

@@ -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) => {