fix(admin): 编辑消息/切换会话保护、行内编辑撤销与上传进度反馈
- 编辑旧消息会删除其后全部消息时,先弹确认告知影响范围 - 有待发送附件时切换会话,先确认再丢弃(避免静默删除已上传文件) - EditableCell 保存成功后提供 6 秒内"撤销"入口(把旧值重新保存), 重新编辑时自动清除 - 导入向导/重新上传显示上传进度条与百分比,不再"假死" - AI 附件上传透传进度给附件列表 - 租赁合同上传显示按钮 loading 与上传百分比
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,11 +189,32 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
|||||||
|
|
||||||
const switchConversation = useCallback(
|
const switchConversation = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
|
const doSwitch = () => {
|
||||||
discardPendingAttachments();
|
discardPendingAttachments();
|
||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
setActiveConversationKey(key);
|
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(
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ export function useAiChatMessageActions({
|
|||||||
setEditingMessageId(null);
|
setEditingMessageId(null);
|
||||||
if (content === messageInfo.message.content) return;
|
if (content === messageInfo.message.content) return;
|
||||||
|
|
||||||
|
// 编辑旧消息会删除其后的全部消息并重新生成,需先告知用户
|
||||||
|
const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id);
|
||||||
|
const followingCount = index >= 0 ? messagesRef.current.length - index - 1 : 0;
|
||||||
|
const doEdit = () => {
|
||||||
setMessage(messageInfo.id, (info) => ({
|
setMessage(messageInfo.id, (info) => ({
|
||||||
message: {
|
message: {
|
||||||
...info.message,
|
...info.message,
|
||||||
@@ -303,7 +307,6 @@ export function useAiChatMessageActions({
|
|||||||
metadata: { ...info.message.metadata, edited: true },
|
metadata: { ...info.message.metadata, edited: true },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id);
|
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id);
|
for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id);
|
||||||
}
|
}
|
||||||
@@ -315,11 +318,24 @@ export function useAiChatMessageActions({
|
|||||||
reasoningEffort: deepThinking ? 'high' : null,
|
reasoningEffort: deepThinking ? 'high' : null,
|
||||||
editMessageId: messageId,
|
editMessageId: messageId,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
if (followingCount > 0) {
|
||||||
|
modal.confirm({
|
||||||
|
title: '编辑消息将删除后续内容',
|
||||||
|
content: `编辑这条消息会删除其后的 ${followingCount} 条消息并重新生成回答,此操作不可恢复。`,
|
||||||
|
okText: '继续编辑',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: doEdit,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doEdit();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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%' }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user