fix(admin): 编辑消息/切换会话保护、行内编辑撤销与上传进度反馈
- 编辑旧消息会删除其后全部消息时,先弹确认告知影响范围 - 有待发送附件时切换会话,先确认再丢弃(避免静默删除已上传文件) - EditableCell 保存成功后提供 6 秒内"撤销"入口(把旧值重新保存), 重新编辑时自动清除 - 导入向导/重新上传显示上传进度条与百分比,不再"假死" - AI 附件上传透传进度给附件列表 - 租赁合同上传显示按钮 loading 与上传百分比
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%' }}>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user