feat(frontend): unify attachment management spec and implement fetch+blob download\n\n- Implement forced download via fetch blob, delayed delete etc in repairOrder module\n- Add Attachment Management Unified Standard doctrine to cursorrules-frontend.md so future pages keep consistency\n- Update change log for download preview fix

This commit is contained in:
zlwu committed 2025-07-11 06:36:45 +08:00
1 parent 1540bd1a1b
commit 4ef2208a0c
11 files changed
+1318 -108

No files matched your search

+33 -5
View File
@@ -11,7 +11,7 @@ import {
RepairStatisticsVO,
ServiceEvaluationForm,
ServiceEvaluationVO
} from '@/api/hq/hq/pm/types';
} from '@/api/hq/pm/types';
/**
* 查询报修工单列表
@@ -161,7 +161,7 @@ export const uploadAttachment = (orderId: string | number, uploadStage: string,
if (description) {
formData.append('description', description);
}
return request({
url: '/hq/pm/attachment/upload',
method: 'post',
@@ -183,17 +183,17 @@ export const uploadAttachments = (orderId: string | number, uploadStage: string,
const formData = new FormData();
formData.append('orderId', orderId.toString());
formData.append('uploadStage', uploadStage);
files.forEach((file, index) => {
formData.append('files', file);
});
if (descriptions) {
descriptions.forEach((desc, index) => {
formData.append('descriptions', desc);
});
}
return request({
url: '/hq/pm/attachment/upload/batch',
method: 'post',
@@ -293,3 +293,31 @@ export const getServiceEvaluation = (orderId: string | number): AxiosPromise<Ser
method: 'get'
});
};
/**
* 更新附件的工单ID(用于新增工单时关联已上传的附件)
* @param attachmentId 附件ID
* @param orderId 工单ID
*/
export const updateAttachmentOrderId = (attachmentId: string | number, orderId: string | number): AxiosPromise<any> => {
return request({
url: `/hq/pm/attachment/${attachmentId}/order/${orderId}`,
method: 'put'
});
};
/**
* 批量更新附件的工单ID(用于新增工单时关联已上传的附件)
* @param attachmentIds 附件ID列表
* @param orderId 工单ID
*/
export const batchUpdateAttachmentOrderId = (attachmentIds: Array<string | number>, orderId: string | number): AxiosPromise<any> => {
return request({
url: '/hq/pm/attachment/batch-update-order',
method: 'put',
data: {
attachmentIds: attachmentIds,
orderId: orderId
}
});
};
+365 -69
View File
@@ -227,6 +227,7 @@
border
:data="repairOrderList"
@selection-change="handleSelectionChange"
@row-dblclick="handleDetail"
style="width: 100%"
:scroll-x="true"
class="scroll-table"
@@ -429,7 +430,7 @@
</el-card>
<!-- 添加或修改报修工单对话框 -->
<el-dialog :title="dialog.title" v-model="dialog.visible" width="800px" append-to-body>
<el-dialog :title="dialog.title" v-model="dialog.visible" width="900px" append-to-body>
<el-form ref="repairOrderFormRef" :model="form" :rules="rules" label-width="120px">
<el-row :gutter="20">
<el-col :span="12">
@@ -529,7 +530,7 @@
:action="uploadAction"
:headers="uploadHeaders"
:data="uploadData"
:auto-upload="false"
:auto-upload="true"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
@@ -545,7 +546,8 @@
<template #tip>
<div class="el-upload__tip">
支持图片、视频、PDF、Word文档等格式,单个文件大小不超过10MB,最多上传15个文件。
<span class="el-upload__tip" v-if="!form.id" style="color: #ff0000">(新增时文件将在保存工单后上传)</span>
<span class="el-upload__tip" v-if="!form.id" style="color: #ff0000">(新增时文件会立即上传,保存工单后自动关联)</span>
<span class="el-upload__tip" v-else style="color: #ff0000">(编辑时文件会立即上传并关联到当前工单)</span>
</div>
</template>
@@ -569,9 +571,26 @@
/>
<div v-else class="upload-file-placeholder">
<el-icon><Document /></el-icon>
<span class="file-ext">{{ file.name }}</span>
<span class="file-ext">{{ getFileExt(file.name) }}</span>
</div>
</div>
<!-- 文件名称 -->
<div class="upload-file-name" :title="file.name">
{{ file.name }}
</div>
<!-- 操作按钮区域 (与预览界面样式一致) -->
<div class="upload-file-card-actions" v-if="file.status && file.status !== 'ready'">
<el-button-group size="small">
<el-button type="success" @click.stop="handleDownloadAttachment(file)" title="下载" :disabled="file.status !== 'success'">
<el-icon><Download /></el-icon>
</el-button>
<el-button type="danger" @click.stop="handleRemoveAttachmentConfirm(file)" title="删除">
<el-icon><Delete /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
</template>
</el-upload>
@@ -876,6 +895,8 @@
<p><strong>报修标题:</strong>{{ currentOrder?.title }}</p>
<p><strong>报修地点:</strong>{{ currentOrder?.repairLocation }}</p>
<p><strong>维修类型:</strong>{{ getCategoryNameById(currentOrder?.categoryId) }}</p>
<p><strong>问题描述:</strong>{{ currentOrder?.description }}</p>
<p><strong>备注信息:</strong>{{ currentOrder?.remark }}</p>
</div>
</el-form-item>
<el-form-item label="分派维修员" prop="assigneeId">
@@ -928,7 +949,7 @@
<div class="progress-card">
<div class="progress-header">
<div class="progress-type-status">
<el-tag :type="getProgressTypeTag(progress.progressType) as any" size="small">
<el-tag :type="getProgressTypeTag(progress.progressType)" size="small">
{{ getProgressTypeText(progress.progressType) }}
</el-tag>
<span class="progress-status">{{ progress.progressStatus }}</span>
@@ -997,14 +1018,14 @@
<!-- 进度更新对话框 -->
<el-dialog title="进度更新" v-model="updateProgressDialog.visible" width="600px" append-to-body>
<el-form ref="progressFormRef" :model="progressForm" :rules="progressRules" label-width="120px">
<el-form-item label="工单编号" prop="orderId">
<el-input v-model="progressForm.orderId" placeholder="请输入工单编号" />
<el-form-item label="工单编号" prop="orderNo">
<el-input v-model="progressForm.orderNo" placeholder="请输入工单编号" />
</el-form-item>
<el-form-item label="进度类型" prop="progressType">
<!-- <el-form-item label="进度类型" prop="progressType">
<el-select v-model="progressForm.progressType" placeholder="请选择进度类型" style="width: 100%">
<el-option v-for="type in progressTypeOptions" :key="type.value" :label="type.label" :value="type.value" />
</el-select>
</el-form-item>
</el-form-item> -->
<el-form-item label="进度状态" prop="progressStatus">
<el-input v-model="progressForm.progressStatus" placeholder="请输入进度状态" />
</el-form-item>
@@ -1041,17 +1062,17 @@
<!-- 完成工单对话框 -->
<el-dialog title="完成工单" v-model="completeDialog.visible" width="600px" append-to-body>
<el-form ref="completeFormRef" :model="completeForm" :rules="completeRules" label-width="120px">
<el-form-item label="工单编号" prop="orderId">
<el-input v-model="completeForm.orderId" placeholder="请输入工单编号" />
<el-form-item label="工单编号" prop="orderNo">
<el-input v-model="completeForm.orderNo" placeholder="请输入工单编号" readonly disabled />
</el-form-item>
<el-form-item label="维修结果" prop="repairResult">
<el-input v-model="completeForm.repairResult" placeholder="请输入维修结果" />
</el-form-item>
<el-form-item label="使用材料" prop="usedMaterials">
<el-input v-model="completeForm.usedMaterials" placeholder="请输入使用材料" />
<el-input v-model="completeForm.usedMaterials" type="textarea" placeholder="请输入使用材料" />
</el-form-item>
<el-form-item label="后续建议" prop="followSuggestions">
<el-input v-model="completeForm.followSuggestions" placeholder="请输入后续建议" />
<el-input v-model="completeForm.followSuggestions" type="textarea" placeholder="请输入后续建议" />
</el-form-item>
</el-form>
<template #footer>
@@ -1206,7 +1227,8 @@ import {
getRepairAttachments,
uploadAttachment,
uploadAttachments,
deleteAttachment
deleteAttachment,
batchUpdateAttachmentOrderId
} from '@/api/hq/pm/repairOrder';
import { getRepairCategoryOptions, getRepairCategoryTree } from '@/api/hq/pm/repairCategory';
import {
@@ -1224,7 +1246,7 @@ import {
import { useUserStore } from '@/store/modules/user';
import { getInfo } from '@/api/login'; // 添加获取用户详细信息的API
import { getToken } from '@/utils/auth'; // 修复:添加getToken导入
import { vLoading } from 'element-plus';
import { vLoading, ElLoading } from 'element-plus';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
@@ -1266,7 +1288,7 @@ const workerOptions = ref<Array<{ value: string | number; label: string; userNam
// 附件相关数据
const attachmentList = ref<any[]>([]);
const uploadAction = ref('/dev-api/pm/attachment/upload'); // 修复:添加/dev-api前缀
const uploadAction = ref('/dev-api/hq/pm/attachment/upload'); // 修复:使用正确的API路径
const uploadHeaders = ref({
'Authorization': `Bearer ${getToken() || ''}`,
'clientid': import.meta.env.VITE_APP_CLIENT_ID || 'e5cd7e4891bf95d1d19206ce24a7b32e'
@@ -1276,6 +1298,12 @@ const uploadData = ref<any>({});
// 新增:临时文件存储,用于新增时收集文件
const tempFileList = ref<File[]>([]);
// 新增:临时附件ID列表,用于新增时收集已上传附件的ID
const tempAttachmentIds = ref<string[]>([]);
// 待删除的附件ID列表 (保存时统一删除)
const pendingDeleteAttachmentIds = ref<string[]>([]);
// 字典数据
const pm_priority_level = ref([
{ value: '1', label: '非常紧急' },
@@ -1830,24 +1858,53 @@ const submitForm = () => {
});
};
/** 处理待上传的附件 */
/** 处理待关联的附件 */
const handlePendingAttachments = async (orderId: string | number) => {
// 处理临时文件列表(新增时的文件)
if (tempFileList.value.length > 0) {
try {
console.log('开始上传临时文件,文件数量:', tempFileList.value.length);
for (const file of tempFileList.value) {
await uploadAttachment(String(orderId), '1', file, '');
console.log('成功上传文件:', file.name);
}
proxy?.$modal.msgSuccess(`成功上传 ${tempFileList.value.length} 个附件`);
let hasOperations = false;
let successMessages: string[] = [];
let errorMessages: string[] = [];
// 清空临时文件列表
// 处理待删除的附件(编辑模式)
if (pendingDeleteAttachmentIds.value.length > 0) {
try {
console.log('开始删除标记的附件,数量:', pendingDeleteAttachmentIds.value.length);
// 批量删除附件
for (const attachmentId of pendingDeleteAttachmentIds.value) {
await deleteAttachment(attachmentId);
}
console.log('成功删除附件IDs:', pendingDeleteAttachmentIds.value);
successMessages.push(`成功删除 ${pendingDeleteAttachmentIds.value.length} 个附件`);
// 清空待删除列表
pendingDeleteAttachmentIds.value = [];
hasOperations = true;
} catch (error) {
console.error('附件删除失败:', error);
errorMessages.push('部分附件删除失败');
}
}
// 处理新增模式下已上传的附件(更新order_id)
if (tempAttachmentIds.value.length > 0) {
try {
console.log('开始关联临时附件到工单,附件数量:', tempAttachmentIds.value.length);
// 调用API批量更新附件的order_id
await batchUpdateAttachmentOrderId(tempAttachmentIds.value, String(orderId));
console.log('成功关联附件IDs:', tempAttachmentIds.value, '到工单:', orderId);
successMessages.push(`成功关联 ${tempAttachmentIds.value.length} 个附件到工单`);
// 清空临时附件ID列表
tempAttachmentIds.value = [];
tempFileList.value = [];
attachmentList.value = [];
hasOperations = true;
} catch (error) {
console.error('附件上传失败:', error);
proxy?.$modal.msgWarning('工单保存成功,但部分附件上传失败');
console.error('附件关联失败:', error);
errorMessages.push('部分附件关联失败');
}
}
@@ -1865,13 +1922,21 @@ const handlePendingAttachments = async (orderId: string | number) => {
fileItem.status = 'success';
}
}
if (tempFileList.value.length === 0) {
// 只有当没有临时文件时才显示这个提示,避免重复提示
proxy?.$modal.msgSuccess(`成功上传 ${newFiles.length} 个新附件`);
}
successMessages.push(`成功上传 ${newFiles.length} 个新附件`);
hasOperations = true;
} catch (error) {
console.error('新附件上传失败:', error);
proxy?.$modal.msgWarning('工单保存成功,但部分新附件上传失败');
errorMessages.push('部分新附件上传失败');
}
}
// 显示操作结果消息
if (hasOperations) {
if (successMessages.length > 0) {
proxy?.$modal.msgSuccess(successMessages.join(','));
}
if (errorMessages.length > 0) {
proxy?.$modal.msgWarning(`工单保存成功,但${errorMessages.join(',')}`);
}
}
};
@@ -2227,16 +2292,27 @@ const handleBeforeUpload = (file: File) => {
return false;
}
// 新增时不直接上传,而是收集文件
if (!form.value.id) {
return false; // 阻止自动上传
// 设置上传参数
const uploadParams: any = {
uploadStage: '1', // 报修申请阶段
description: `${form.value.title || '报修工单'}相关附件` // 添加描述信息
};
// 只有编辑模式时才传递orderId
if (form.value.id) {
uploadParams.orderId = form.value.id;
}
// 编辑时设置上传参数
uploadData.value = {
orderId: form.value.id,
uploadStage: '1' // 报修申请阶段
};
uploadData.value = uploadParams;
console.log('准备上传文件:', {
文件名: file.name,
文件大小: (file.size / 1024 / 1024).toFixed(2) + 'MB',
工单ID: uploadParams.orderId || '待关联',
上传阶段: uploadParams.uploadStage,
描述: uploadParams.description,
模式: form.value.id ? '编辑模式' : '新增模式'
});
return true;
};
@@ -2244,11 +2320,41 @@ const handleBeforeUpload = (file: File) => {
/** 上传成功 */
const handleUploadSuccess = (response: any, file: any) => {
console.log('上传成功:', response, file);
proxy?.$modal.msgSuccess(`文件 ${file.name} 上传成功`);
// 更新附件列表
if (response.code === 200) {
// 这里可以刷新附件列表或更新UI
proxy?.$modal.msgSuccess(`文件 ${file.name} 上传成功`);
// 立即记录附件数据
const attachmentData = response.data;
if (attachmentData && attachmentData.id) {
// 如果是新增模式,收集附件ID到临时列表
if (!form.value.id) {
tempAttachmentIds.value.push(attachmentData.id);
console.log('新增模式:已收集附件ID', attachmentData.id, '当前临时附件ID列表:', tempAttachmentIds.value);
}
// 更新文件对象的相关信息
file.attachmentId = attachmentData.id;
file.status = 'success';
file.url = attachmentData.filePath;
// 如果是编辑模式,附件已经自动关联到工单,直接更新UI
if (form.value.id) {
console.log('编辑模式:附件已关联到工单', form.value.id, '附件ID:', attachmentData.id);
// 可以选择刷新附件列表以确保数据同步
// loadAttachments(form.value.id);
}
console.log('附件记录成功:', {
附件ID: attachmentData.id,
文件名: attachmentData.fileName,
文件路径: attachmentData.filePath,
工单ID: form.value.id || '待关联',
模式: form.value.id ? '编辑' : '新增'
});
}
} else {
proxy?.$modal.msgError(`文件 ${file.name} 上传失败: ${response.msg || '未知错误'}`);
}
};
@@ -2258,25 +2364,108 @@ const handleUploadError = (error: Error, file: any) => {
proxy?.$modal.msgError(`文件 ${file.name} 上传失败`);
};
/** 移除附件 */
const handleRemoveAttachment = async (file: any) => {
// 如果是已上传的文件(编辑模式下的已有附件)
if (file.response && file.response.data && file.response.data.id) {
try {
await deleteAttachment(file.response.data.id);
proxy?.$modal.msgSuccess('附件删除成功');
/** 下载附件 */
const handleDownloadAttachment = async (file: any) => {
let downloadUrl = '';
let fileName = file.name || 'attachment';
// 编辑模式下删除附件后,从attachmentList中移除
const index = attachmentList.value.findIndex((item) => item.uid === file.uid);
if (index > -1) {
attachmentList.value.splice(index, 1);
}
} catch (error) {
console.error('删除附件失败:', error);
proxy?.$modal.msgError('删除附件失败');
}
// 如果文件已经上传成功,使用服务器返回的路径
if (file.response && file.response.data && file.response.data.filePath) {
downloadUrl = file.response.data.filePath;
fileName = file.response.data.fileName || fileName;
}
// 如果是临时文件(新增时添加的文件),从临时列表中删除
// 如果是已有的附件,使用文件的url
else if (file.url) {
downloadUrl = file.url;
}
// 如果是本地文件,直接使用raw文件
else if (file.raw) {
const link = document.createElement('a');
link.href = URL.createObjectURL(file.raw);
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
console.log('下载本地文件:', fileName);
return;
}
if (!downloadUrl) {
proxy?.$modal.msgError('无法获取文件下载地址');
return;
}
// 对于服务器文件,使用fetch获取内容强制下载
try {
const loading = ElLoading.service({
text: '正在下载文件...',
target: document.body
});
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
loading?.close();
console.log('下载服务器文件成功:', fileName);
} catch (error) {
console.error('下载文件失败:', error);
proxy?.$modal.msgError('下载文件失败,请稍后重试');
}
};
/** 删除附件确认 */
const handleRemoveAttachmentConfirm = async (file: any) => {
await proxy?.$modal.confirm('确定要删除该附件吗?删除操作将在保存时生效。');
handleRemoveAttachment(file);
};
/** 移除附件 (修改为延迟删除) */
const handleRemoveAttachment = (file: any) => {
// 如果是已上传的文件(编辑模式下的已有附件或新增模式下已上传的附件)
if (file.response && file.response.data && file.response.data.id) {
// 不立即删除数据库记录,而是标记为待删除
const attachmentId = file.response.data.id;
// 如果是新增模式,从临时附件ID列表中移除
if (!form.value.id) {
const attachmentIndex = tempAttachmentIds.value.indexOf(attachmentId);
if (attachmentIndex > -1) {
tempAttachmentIds.value.splice(attachmentIndex, 1);
console.log('新增模式:已从临时附件ID列表中移除:', attachmentId, '剩余附件数:', tempAttachmentIds.value.length);
}
} else {
// 编辑模式:添加到待删除列表
if (!pendingDeleteAttachmentIds.value.includes(attachmentId)) {
pendingDeleteAttachmentIds.value.push(attachmentId);
console.log('编辑模式:已标记附件为待删除:', attachmentId, '待删除列表:', pendingDeleteAttachmentIds.value);
}
}
// 从attachmentList中移除
const index = attachmentList.value.findIndex((item) => item.uid === file.uid);
if (index > -1) {
attachmentList.value.splice(index, 1);
}
proxy?.$modal.msgSuccess('附件已移除,将在保存时生效');
}
// 如果是临时文件(新增时添加但未上传的文件),从临时列表中删除
else if (file.raw && !form.value.id) {
const index = tempFileList.value.findIndex((f) => f.name === file.raw.name && f.size === file.raw.size);
if (index > -1) {
@@ -2321,7 +2510,9 @@ const reset = async (autoFillUserInfo: boolean = false) => {
// 清空附件相关数据
attachmentList.value = [];
tempFileList.value = [];
console.log('已清空附件列表和临时文件列表');
tempAttachmentIds.value = [];
pendingDeleteAttachmentIds.value = [];
console.log('已清空附件列表、临时文件列表、临时附件ID列表和待删除附件列表');
// 如果是新增操作,自动填充当前用户信息
if (autoFillUserInfo) {
@@ -2497,8 +2688,46 @@ const cancelEvaluation = () => {
};
/** 下载附件 */
const downloadAttachment = (attachment: any) => {
window.open(attachment.filePath, '_blank');
const downloadAttachment = async (attachment: any) => {
if (!attachment.filePath) {
proxy?.$modal.msgError('无法获取文件下载地址');
return;
}
try {
// 显示加载提示
const loading = ElLoading.service({
text: '正在下载文件...',
target: document.body
});
// 通过fetch获取文件内容,强制下载
const response = await fetch(attachment.filePath);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.href = url;
link.download = attachment.fileName || '附件';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 释放blob URL
window.URL.revokeObjectURL(url);
loading?.close();
console.log('下载附件成功:', attachment.fileName || '附件');
} catch (error) {
console.error('下载附件失败:', error);
proxy?.$modal.msgError('下载文件失败,请稍后重试');
}
};
/** 预览附件 */
@@ -2602,8 +2831,8 @@ const getProgressIconColor = (progressType: string) => {
};
/** 获取进度类型标签类型 */
const getProgressTypeTag = (progressType: string) => {
const tagMap: Record<string, string> = {
const getProgressTypeTag = (progressType: string): 'success' | 'warning' | 'info' | 'primary' | 'danger' => {
const tagMap: Record<string, 'success' | 'warning' | 'info' | 'primary' | 'danger'> = {
'1': 'primary',
'2': 'success',
'3': 'warning',
@@ -2827,6 +3056,8 @@ const getFileExt = (fileName: string): string => {
/* 工单信息样式 */
.order-info {
width: 100%;
box-sizing: border-box; // 使内边距计入总宽度,避免超出表单内容区域
background-color: #f8f9fa;
padding: 16px;
border-radius: 8px;
@@ -3505,18 +3736,35 @@ const getFileExt = (fileName: string): string => {
display: flex !important;
}
/* Element Plus upload 组件样式覆盖 */
:deep(.el-upload-list--picture-card) {
.el-upload-list__item {
width: 166px !important;
height: auto !important;
min-height: 180px !important;
margin: 0 8px 8px 0 !important;
border-radius: 6px !important;
overflow: hidden !important;
}
}
/* 卡片整体布局包含文件名 */
.upload-file-card {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
min-height: 180px;
position: relative;
}
.upload-file-thumb {
width: 100%;
aspect-ratio: 1 / 1;
height: 120px;
position: relative;
overflow: hidden;
border-radius: 4px 4px 0 0;
}
.upload-file-thumb .el-image,
@@ -3525,13 +3773,61 @@ const getFileExt = (fileName: string): string => {
height: 100%;
}
.upload-file-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #fafafa;
.el-icon {
font-size: 24px;
color: #c0c4cc;
margin-bottom: 8px;
}
.file-ext {
font-size: 12px;
color: #909399;
text-align: center;
word-break: break-all;
padding: 0 8px;
}
}
.upload-file-name {
width: 100%;
margin-top: 4px;
padding: 8px 4px 4px 4px;
font-size: 12px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #606266;
background-color: #fff;
flex-shrink: 0;
}
/* 上传文件卡片操作按钮样式 (与预览界面保持一致) */
.upload-file-card-actions {
width: 100%;
padding: 6px 8px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: center;
background-color: #fafafa;
margin-top: auto;
flex-shrink: 0;
.el-button-group {
.el-button {
padding: 4px 6px;
font-size: 12px;
.el-icon {
font-size: 14px;
}
}
}
}
</style>