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

+100
View File
@@ -809,3 +809,103 @@ export default defineConfig({
1. **按钮规范**: 工具栏第一个新增按钮必须使用 `type="primary" plain`,其他按钮使用默认样式
2. **颜色系统**: 使用最新的 Art Design Pro 颜色变量
3. **文档同步**: 代码变更后必须同步更新相关文档
## 4. 附件管理统一规范(强制性)
> 适用范围:所有使用 **Element Plus** + **Vue 3 + TS** 的前端页面(弹窗、新建、编辑、详情、预览等)涉及“附件/文件”功能的模块,必须遵循本规范,以保持 UI 及交互的一致性。
### 4.1 组件结构
| 组件 | 说明 |
|------|------|
| `<el-upload list-type="picture-card">` | 核心上传组件,拖拽 + 点击上传,**单文件最大 10 MB**,**最多 15 个文件** |
| 自定义 `#file` 模板 | 自绘「卡片样式」:缩略图 + 文件名 + 操作按钮组 |
| `<el-button-group>` | 卡片底部操作按钮:下载(success)、删除(danger) |
| 自定义 CSS 类 | `.upload-file-card / thumb / name / actions` 等,详见样式规范 |
### 4.2 功能实现
1. **上传 Upload**
- `auto-upload="true"` + 立即上传
- `accept="image/*,video/*,.pdf,.doc,.docx,.txt"`
- 上传前 (`before-upload`) 校验大小 <10 MB
- 上传成功:
- 新建模式:将返回的 `id` 收集到 `tempAttachmentIds`
- 编辑模式:文件已自动关联到工单
- 上传失败:Element Plus message 提示
2. **下载 Download (强制使用 fetch + blob)**
- 统一函数 `handleDownloadAttachment(file)` / `downloadAttachment(attachment)`
- 流程:`fetch(filePath) → blob → URL.createObjectURL → <a download>`
- 使用 `ElLoading.service()` 显示 *正在下载文件…*
- 下载完成后 `revokeObjectURL` 释放内存
- 解决浏览器对图片/PDF 默认预览导致无法下载的问题
3. **删除 Delete(延迟删除机制)**
- 点击删除 → `proxy.$modal.confirm()` 二次确认
- 已上传文件:只把 `attachment.id` 放入 `pendingDeleteAttachmentIds`,**不立即调用后端**
- 表单保存时统一调用 `deleteAttachment` 批量删除
- 新增未上传文件:直接从 `tempFileList` / `attachmentList` 移除
4. **预览 Preview**
- 判断 `fileType === 'image'` → 使用 `Element Plus ImageViewer` 预览
- 其他类型文件 `window.open(filePath,'_blank')`
- 详情弹窗附件列表同样遵循该预览逻辑
### 4.3 样式规范(SCSS)
```scss
/* 让附件卡片一行展示 4 个 */
:deep(.el-upload-list--picture-card) {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 9px;
margin-top: 12px;
.el-upload-list__item {
width: 166px !important;
min-height: 180px !important;
border-radius: 6px;
overflow: hidden;
}
}
/* 卡片整体布局 */
.upload-file-card {
display: flex;
flex-direction: column;
min-height: 180px;
}
.upload-file-thumb { height: 120px; overflow: hidden; }
.upload-file-name { font-size: 12px; padding: 8px 4px 4px; text-align: center; overflow: hidden; }
.upload-file-card-actions {
padding: 6px 8px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
display: flex; justify-content: center;
}
```
### 4.4 统一变量 & Hook
```ts
const attachmentList = ref<any[]>([]); // el-upload file-list
const tempFileList = ref<File[]>([]); // 新建时未上传的本地文件
const tempAttachmentIds = ref<string[]>([]); // 新建时已上传待关联附件 id
const pendingDeleteAttachmentIds = ref<string[]>([]); // 编辑时标记待删除附件 id
```
### 4.5 集成步骤(页面级)
1. 在 `<script setup>` 引入上述变量 + 上传/下载/删除/预览函数
2. 使用统一的上传组件模板(参考 `web/src/views/hq/pm/repairOrder/index.vue`)
3. 在 *保存/提交* 逻辑中调用 `handlePendingAttachments(orderId)` 处理上传 & 删除
4. 样式直接复制 *样式规范* 中的片段或通过全局样式引入
### 4.6 强制性要求
- **🚨 不得** 在其他页面自定义下载逻辑(必须使用 fetch+blob)
- **🚨 不得** 直接删除附件数据库记录;必须延迟删除
- **🚨 不得** 修改附件卡片 UI 结构及尺寸;需保持一致
- **✅ 必须** 在新页面开发时引用本规范并注明 *附件管理统一规范* 版本
> **版本**: 2025-01-27 附件管理统一规范 v1.0
+49 -31
View File
@@ -77,7 +77,7 @@ show_help() {
# 检查环境
check_environment() {
echo -e "${BLUE}🔍 检查运行环境...${NC}"
# 检查Java版本
if command -v java &> /dev/null; then
JAVA_VERSION=$(java -version 2>&1 | head -n 1 | cut -d'"' -f2)
@@ -86,7 +86,7 @@ check_environment() {
echo -e " ❌ ${RED}未找到Java环境${NC}"
return 1
fi
# 检查Maven版本
if command -v mvn &> /dev/null; then
MVN_VERSION=$(mvn -version 2>/dev/null | head -n 1 | cut -d' ' -f3)
@@ -95,7 +95,7 @@ check_environment() {
echo -e " ❌ ${RED}未找到Maven环境${NC}"
return 1
fi
# 检查目标模块
if [ -d "$DEFAULT_MODULE" ]; then
echo -e " ✅ 目标模块: ${GREEN}$DEFAULT_MODULE${NC}"
@@ -103,14 +103,14 @@ check_environment() {
echo -e " ❌ ${RED}未找到目标模块: $DEFAULT_MODULE${NC}"
return 1
fi
# 检查热加载配置
if grep -q "spring-boot-devtools" $DEFAULT_MODULE/pom.xml; then
echo -e " ✅ 热加载配置: ${GREEN}已启用${NC}"
else
echo -e " ⚠️ 热加载配置: ${YELLOW}未配置${NC}"
fi
# 检查配置文件
CONFIG_FILE="$DEFAULT_MODULE/src/main/resources/application-$DEFAULT_PROFILE.yml"
if [ -f "$CONFIG_FILE" ]; then
@@ -119,7 +119,7 @@ check_environment() {
echo -e " ❌ ${RED}未找到配置文件: $CONFIG_FILE${NC}"
return 1
fi
echo -e "${GREEN}✅ 环境检查完成${NC}"
return 0
}
@@ -164,13 +164,27 @@ clean_build() {
# 构建项目
build_project() {
echo -e "${BLUE}🔨 编译项目...${NC}"
echo -e "${BLUE}🔨 编译并打包项目...${NC}"
cd $DEFAULT_MODULE
mvn compile -q
mvn clean package -q
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ 编译完成${NC}"
echo -e "${GREEN}✅ 编译打包完成${NC}"
else
echo -e "${RED}❌ 编译失败${NC}"
echo -e "${RED}❌ 编译打包失败${NC}"
exit 1
fi
cd ..
}
# 新增:构建 ruoyi-modules
build_ruoyi_modules() {
echo -e "${BLUE}🔨 开始编译并打包 ruoyi-modules...${NC}"
cd ruoyi-modules
mvn clean install -q
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ ruoyi-modules 编译打包完成${NC}"
else
echo -e "${RED}❌ ruoyi-modules 编译打包失败${NC}"
exit 1
fi
cd ..
@@ -183,7 +197,7 @@ start_application() {
local debug_mode=$3
local hot_reload=$4
local jvm_opts=$5
echo -e "${BLUE}🚀 启动应用...${NC}"
echo -e "${CYAN}配置信息:${NC}"
echo -e " 📂 模块: ${GREEN}$DEFAULT_MODULE${NC}"
@@ -192,42 +206,42 @@ start_application() {
echo -e " 🔥 热加载: ${GREEN}$([ "$hot_reload" = "true" ] && echo "启用" || echo "禁用")${NC}"
echo -e " 🐛 调试模式: ${GREEN}$([ "$debug_mode" = "true" ] && echo "启用" || echo "禁用")${NC}"
echo ""
cd $DEFAULT_MODULE
# 构建Maven命令
MVN_CMD="mvn spring-boot:run"
# 添加环境配置
MVN_CMD="$MVN_CMD -Dspring-boot.run.profiles=$profile"
# 添加端口配置
if [ "$port" != "$DEFAULT_PORT" ]; then
MVN_CMD="$MVN_CMD -Dspring-boot.run.arguments=--server.port=$port"
fi
# 添加JVM参数
if [ -n "$jvm_opts" ]; then
MVN_CMD="$MVN_CMD -Dspring-boot.run.jvmArguments=\"$jvm_opts\""
else
MVN_CMD="$MVN_CMD -Dspring-boot.run.jvmArguments=\"$MAVEN_OPTS\""
fi
# 调试模式
if [ "$debug_mode" = "true" ]; then
MVN_CMD="$MVN_CMD -Dspring-boot.run.jvmArguments=\"$MAVEN_OPTS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005\""
echo -e "${PURPLE}🐛 调试端口: 5005${NC}"
fi
# 禁用热加载
if [ "$hot_reload" = "false" ]; then
MVN_CMD="$MVN_CMD -Dspring.devtools.restart.enabled=false"
fi
echo -e "${CYAN}执行命令: ${WHITE}$MVN_CMD${NC}"
echo -e "${GREEN}==================== 应用启动日志 ====================${NC}"
echo ""
# 执行命令
eval $MVN_CMD
}
@@ -243,7 +257,7 @@ main() {
HOT_RELOAD=true
JVM_OPTS=""
CHECK_ONLY=false
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
@@ -294,37 +308,41 @@ main() {
;;
esac
done
# 显示Logo
show_logo
# 仅检查环境
if [ "$CHECK_ONLY" = "true" ]; then
check_environment
exit $?
fi
# 环境检查
if ! check_environment; then
echo -e "${RED}❌ 环境检查失败,请检查配置${NC}"
exit 1
fi
echo ""
# 端口检查
check_port $PORT
# 清理编译
if [ "$CLEAN_BUILD" = "true" ]; then
clean_build
echo ""
fi
# 编译项目
# 先编译打包 ruoyi-modules
build_ruoyi_modules
echo ""
# 再编译打包 ruoyi-admin
build_project
echo ""
# 启动应用
start_application "$PROFILE" "$PORT" "$DEBUG_MODE" "$HOT_RELOAD" "$JVM_OPTS"
}
@@ -333,4 +351,4 @@ main() {
trap 'echo -e "\n${YELLOW}⚠️ 应用启动已中断${NC}"; exit 1' INT TERM
# 执行主函数
main "$@"
main "$@"
@@ -107,7 +107,7 @@ public class HqPmRepairAttachmentController extends BaseController {
@Log(title = "上传报修附件", businessType = BusinessType.INSERT)
@PostMapping("/upload")
public R<HqPmRepairAttachmentVo> uploadFile(
@RequestParam("orderId") Long orderId,
@RequestParam(value = "orderId", required = false) Long orderId,
@RequestParam(value = "uploadStage", defaultValue = "1") String uploadStage,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "description", required = false) String description) {
@@ -183,4 +183,56 @@ public class HqPmRepairAttachmentController extends BaseController {
}
}
/**
* 更新附件的工单ID(用于新增工单时关联已上传的附件)
*/
// @SaCheckPermission("pm:attachment:edit")
@Log(title = "更新附件工单ID", businessType = BusinessType.UPDATE)
@PutMapping("/{attachmentId}/order/{orderId}")
public R<Void> updateAttachmentOrderId(@PathVariable("attachmentId") Long attachmentId,
@PathVariable("orderId") Long orderId) {
return toAjax(attachmentService.updateAttachmentOrderId(attachmentId, orderId));
}
/**
* 批量更新附件的工单ID(用于新增工单时关联已上传的附件)
*/
// @SaCheckPermission("pm:attachment:edit")
@Log(title = "批量更新附件工单ID", businessType = BusinessType.UPDATE)
@PutMapping("/batch-update-order")
public R<Void> batchUpdateAttachmentOrderId(@RequestBody BatchUpdateOrderIdRequest request) {
if (request.getAttachmentIds() == null || request.getAttachmentIds().isEmpty()) {
return R.fail("附件ID列表不能为空");
}
if (request.getOrderId() == null) {
return R.fail("工单ID不能为空");
}
return toAjax(attachmentService.batchUpdateAttachmentOrderId(request.getAttachmentIds(), request.getOrderId()));
}
/**
* 批量更新附件工单ID的请求参数
*/
public static class BatchUpdateOrderIdRequest {
private List<Long> attachmentIds;
private Long orderId;
public List<Long> getAttachmentIds() {
return attachmentIds;
}
public void setAttachmentIds(List<Long> attachmentIds) {
this.attachmentIds = attachmentIds;
}
public Long getOrderId() {
return orderId;
}
public void setOrderId(Long orderId) {
this.orderId = orderId;
}
}
}
@@ -73,4 +73,14 @@ public interface IHqPmRepairAttachmentService {
*/
void updateOrderAttachmentCount(Long orderId);
/**
* 更新附件的工单ID(用于新增工单时关联已上传的附件)
*/
Boolean updateAttachmentOrderId(Long attachmentId, Long orderId);
/**
* 批量更新附件的工单ID(用于新增工单时关联已上传的附件)
*/
Boolean batchUpdateAttachmentOrderId(List<Long> attachmentIds, Long orderId);
}
@@ -303,6 +303,99 @@ public class HqPmRepairAttachmentServiceImpl implements IHqPmRepairAttachmentSer
repairOrderMapper.update(null, updateWrapper);
}
/**
* 更新附件的工单ID(用于新增工单时关联已上传的附件)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean updateAttachmentOrderId(Long attachmentId, Long orderId) {
if (attachmentId == null || orderId == null) {
log.warn("更新附件工单ID参数不能为空: attachmentId={}, orderId={}", attachmentId, orderId);
return false;
}
// 检查附件是否存在
HqPmRepairAttachmentVo attachment = queryById(attachmentId);
if (attachment == null) {
log.warn("附件不存在: attachmentId={}", attachmentId);
return false;
}
// 更新附件的工单ID
LambdaUpdateWrapper<HqPmRepairAttachment> updateWrapper = Wrappers.lambdaUpdate();
updateWrapper.eq(HqPmRepairAttachment::getId, attachmentId)
.set(HqPmRepairAttachment::getOrderId, orderId);
boolean success = baseMapper.update(null, updateWrapper) > 0;
if (success) {
log.info("成功更新附件工单ID: attachmentId={}, orderId={}", attachmentId, orderId);
// 更新工单的附件数量
updateOrderAttachmentCount(orderId);
// 如果原来有工单ID,也需要更新原工单的附件数量
if (attachment.getOrderId() != null && !attachment.getOrderId().equals(orderId)) {
updateOrderAttachmentCount(attachment.getOrderId());
}
} else {
log.error("更新附件工单ID失败: attachmentId={}, orderId={}", attachmentId, orderId);
}
return success;
}
/**
* 批量更新附件的工单ID(用于新增工单时关联已上传的附件)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean batchUpdateAttachmentOrderId(List<Long> attachmentIds, Long orderId) {
if (attachmentIds == null || attachmentIds.isEmpty() || orderId == null) {
log.warn("批量更新附件工单ID参数不能为空: attachmentIds={}, orderId={}", attachmentIds, orderId);
return false;
}
try {
// 获取要更新的附件列表(在更新前)
List<HqPmRepairAttachmentVo> existingAttachments = new ArrayList<>();
for (Long attachmentId : attachmentIds) {
HqPmRepairAttachmentVo attachment = queryById(attachmentId);
if (attachment != null) {
existingAttachments.add(attachment);
}
}
// 批量更新附件的工单ID
LambdaUpdateWrapper<HqPmRepairAttachment> updateWrapper = Wrappers.lambdaUpdate();
updateWrapper.in(HqPmRepairAttachment::getId, attachmentIds)
.set(HqPmRepairAttachment::getOrderId, orderId);
boolean success = baseMapper.update(null, updateWrapper) > 0;
if (success) {
log.info("成功批量更新附件工单ID: attachmentIds={}, orderId={}, count={}",
attachmentIds, orderId, attachmentIds.size());
// 更新新工单的附件数量
updateOrderAttachmentCount(orderId);
// 更新原来有工单ID的附件所属工单的数量
existingAttachments.stream()
.map(HqPmRepairAttachmentVo::getOrderId)
.filter(oldOrderId -> oldOrderId != null && !oldOrderId.equals(orderId))
.distinct()
.forEach(this::updateOrderAttachmentCount);
} else {
log.error("批量更新附件工单ID失败: attachmentIds={}, orderId={}", attachmentIds, orderId);
}
return success;
} catch (Exception e) {
log.error("批量更新附件工单ID异常: attachmentIds={}, orderId={}, error={}",
attachmentIds, orderId, e.getMessage(), e);
throw e;
}
}
/**
* 根据MIME类型获取文件类型
*/
@@ -29,6 +29,7 @@ import org.dromara.common.core.service.UserService;
import java.util.List;
import java.util.Collection;
import java.util.Date;
import java.util.Map;
/**
* 报修工单Service业务层处理
@@ -140,7 +141,14 @@ public class HqPmRepairOrderServiceImpl implements IHqPmRepairOrderService {
private LambdaQueryWrapper<HqPmRepairOrder> buildQueryWrapper(HqPmRepairOrderBo bo) {
LambdaQueryWrapper<HqPmRepairOrder> lqw = Wrappers.lambdaQuery();
lqw.orderByDesc(HqPmRepairOrder::getCreateTime);
lqw.eq(StringUtils.isNotBlank(bo.getOrderNo()), HqPmRepairOrder::getOrderNo, bo.getOrderNo());
// 使用 LIKE 进行模糊匹配,支持输入部分工单编号
lqw.like(StringUtils.isNotBlank(bo.getOrderNo()), HqPmRepairOrder::getOrderNo, bo.getOrderNo());
// 处理创建时间范围过滤条件
Map<String, Object> params = bo.getParams();
lqw.between(params != null && params.get("beginCreateTime") != null && params.get("endCreateTime") != null,
HqPmRepairOrder::getCreateTime,
params.get("beginCreateTime"),
params.get("endCreateTime"));
lqw.like(StringUtils.isNotBlank(bo.getTitle()), HqPmRepairOrder::getTitle, bo.getTitle());
lqw.like(StringUtils.isNotBlank(bo.getDescription()), HqPmRepairOrder::getDescription, bo.getDescription());
lqw.like(StringUtils.isNotBlank(bo.getRepairLocation()), HqPmRepairOrder::getRepairLocation,
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
# =============================================================================
# 一键启动脚本 (顺序: 监控中心 → 定时任务 → 主业务)
# 功能: 依次启动 start-monitor.sh、start-job.sh、hot-start.sh
# 位置: backend/start-all.sh
# 作者: zlwu
# 时间: 2025-07-10
# =============================================================================
set -e
# -------------- 编码设置 -----------------------------------------------------
export LANG=zh_CN.UTF-8
export LC_ALL=zh_CN.UTF-8
# 确保 Java 与 Maven 进程使用 UTF-8 编码
export JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 $JAVA_TOOL_OPTIONS"
export MAVEN_OPTS="-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 $MAVEN_OPTS"
# -------------- 配置区 -------------------------------------------------------
MONITOR_SCRIPT="./start-monitor.sh" # 监控中心启动脚本
MONITOR_PORT=9090 # 监控中心端口
JOB_SCRIPT="./start-job.sh" # 定时任务启动脚本
JOB_PORT=8800 # 定时任务端口
MAIN_SCRIPT="./hot-start.sh" # 主业务启动脚本
# -------------- 辅助函数 -----------------------------------------------------
COLOR_GREEN='\033[0;32m'
COLOR_YELLOW='\033[1;33m'
COLOR_RED='\033[0;31m'
COLOR_CYAN='\033[0;36m'
COLOR_RESET='\033[0m'
# 等待端口就绪
wait_for_port() {
local port=$1
local name=$2
local retry=0
local max_retry=60 # 最多等待 60 次 (约 120s)
printf "${COLOR_CYAN}⏳ 正在等待 %s (端口 %s) 启动" "$name" "$port"
while ! (command -v nc &>/dev/null && nc -z localhost "$port") 2>/dev/null; do
# 若系统无 nc,则使用 lsof 检测
if ! command -v nc &>/dev/null; then
if lsof -Pi :"$port" -sTCP:LISTEN -t >/dev/null 2>&1; then
break
fi
fi
printf "."
sleep 2
retry=$((retry+1))
if [ $retry -ge $max_retry ]; then
printf "\n${COLOR_RED}❌ 等待 %s 启动超时 (端口 %s 未就绪)${COLOR_RESET}\n" "$name" "$port"
exit 1
fi
done
printf "\n${COLOR_GREEN}✅ %s 已启动${COLOR_RESET}\n" "$name"
}
# 启动脚本并后台运行
start_in_background() {
local script=$1
local log_file=$2
bash "$script" > "$log_file" 2>&1 &
echo $!
}
# 捕获退出信号以清理后台进程
cleanup() {
echo -e "\n${COLOR_YELLOW}⚠️ 检测到退出信号,正在关闭后台服务...${COLOR_RESET}"
[[ -n "$MONITOR_PID" ]] && kill "$MONITOR_PID" 2>/dev/null || true
[[ -n "$JOB_PID" ]] && kill "$JOB_PID" 2>/dev/null || true
exit 0
}
trap cleanup INT TERM
# -------------- 启动流程 -----------------------------------------------------
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$SCRIPT_DIR"
# 1. 启动监控中心
echo -e "${COLOR_GREEN}🚀 启动监控中心...${COLOR_RESET}"
MONITOR_PID=$(start_in_background "$MONITOR_SCRIPT" "logs/monitor.log")
wait_for_port "$MONITOR_PORT" "监控中心"
# 2. 启动定时任务服务
echo -e "${COLOR_GREEN}🚀 启动定时任务服务...${COLOR_RESET}"
JOB_PID=$(start_in_background "$JOB_SCRIPT" "logs/job.log")
wait_for_port "$JOB_PORT" "定时任务服务"
# 3. 启动主业务 (前台运行,方便查看日志)
echo -e "${COLOR_GREEN}🚀 启动主业务服务...${COLOR_RESET}"
exec bash "$MAIN_SCRIPT" "$@"
@@ -0,0 +1,513 @@
# 代码调整记录 - 2025-01-27 - 附件上传逻辑优化
**调整日期**: 2025-01-27
**调整人员**: wuzilong
**调整内容**: 完善物业报修工单附件上传功能,支持删除与下载
## 🎯 调整概述
优化物业报修工单的附件上传功能,实现以下新特性:
1. **延迟删除机制** - 删除附件时不立即操作数据库,保存时统一处理
2. **文件下载功能** - 支持直接下载已上传的附件
3. **视觉交互优化** - 添加悬停显示操作按钮的现代化交互体验
4. **样式统一优化** - 新增界面附件操作按钮与预览界面保持一致
## 📋 详细调整内容
### 1. 核心功能实现
#### 1.1 添加待删除附件跟踪变量
```javascript
// 待删除的附件ID列表 (保存时统一删除)
const pendingDeleteAttachmentIds = ref<string[]>([]);
```
#### 1.2 修改附件删除逻辑
- **原有逻辑**: 删除时立即调用数据库删除API
- **新逻辑**: 删除时仅标记为待删除,保存时统一处理
#### 1.3 新增文件下载功能
```javascript
const handleDownloadAttachment = (file: any) => {
// 支持多种文件来源的下载:
// 1. 已上传文件(服务器路径)
// 2. 本地文件(blob URL)
// 3. 预览文件(file.url)
}
```
### 2. UI/UX 改进
#### 2.1 自定义文件卡片模板 (最终版本)
```vue
<template #file="{ file }">
<div class="upload-file-card">
<!-- 缩略图区域 -->
<div class="upload-file-thumb">
<el-image v-if="isImageFile(file) && file.url" :src="file.url" fit="cover" />
<div v-else class="upload-file-placeholder">
<el-icon><Document /></el-icon>
<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 === 'success' || file.response">
<el-button-group size="small">
<el-button type="success" @click.stop="handleDownloadAttachment(file)">
<el-icon><Download /></el-icon>
</el-button>
<el-button type="danger" @click.stop="handleRemoveAttachmentConfirm(file)">
<el-icon><Delete /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
</template>
```
#### 2.2 交互体验优化
- **✅ 样式统一**: 新增界面操作按钮与预览界面保持完全一致
- **✅ 按钮组布局**: 使用 `el-button-group` 水平排列在底部
- **✅ 视觉效果**: 统一的边框、背景色、按钮尺寸
- **✅ 确认对话框**: 删除前显示确认提示
### 3. 数据处理优化
#### 3.1 保存时统一处理删除
```javascript
const handlePendingAttachments = async (orderId: string | number) => {
// 1. 处理待删除的附件(编辑模式)
if (pendingDeleteAttachmentIds.value.length > 0) {
// 批量删除标记的附件
}
// 2. 处理新增模式下已上传的附件
// 3. 处理编辑模式下新添加的文件
// 4. 统一显示操作结果消息
}
```
#### 3.2 清理逻辑完善
- 表单重置时清空所有附件相关数据
- 包括待删除列表的清理
## 🔧 涉及文件
### 修改文件
- `web/src/views/hq/pm/repairOrder/index.vue`
- 添加 `pendingDeleteAttachmentIds` 变量
- 新增 `handleDownloadAttachment` 函数
- 新增 `handleRemoveAttachmentConfirm` 函数
- 修改 `handleRemoveAttachment` 函数(延迟删除逻辑)
- 优化 `handlePendingAttachments` 函数(统一处理删除)
- **🎨 重构自定义文件卡片模板(样式统一)**
- **🎨 更新操作按钮CSS样式(与预览界面一致)**
## 🎨 CSS 样式更新
### 原有样式(悬停覆盖层)
```scss
/* 已移除 - 悬停显示的覆盖层样式 */
.upload-file-actions { /* 删除 */ }
```
### 新样式(底部按钮组 - 与预览界面一致)
```scss
/* 上传文件卡片操作按钮样式 (与预览界面保持一致) */
.upload-file-card-actions {
padding: 8px 12px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: center;
background-color: #fafafa;
.el-button-group {
.el-button {
padding: 4px 6px;
font-size: 12px;
.el-icon {
font-size: 14px;
}
}
}
}
```
## ✅ 功能验证
### 测试场景
1. **新增工单时**:
- ✅ 上传附件后显示底部操作按钮
- ✅ 按钮样式与预览界面完全一致
- ✅ 下载和删除功能正常工作
- ✅ 删除时显示确认对话框
2. **编辑工单时**:
- ✅ 已有附件可以下载
- ✅ 删除已有附件时标记为待删除
- ✅ 保存时统一执行删除操作
- ✅ 新增附件正常上传
3. **界面一致性**:
- ✅ 新增界面与预览界面按钮样式完全一致
- ✅ 按钮组水平排列,位于文件卡片底部
- ✅ 统一的边框、背景色、按钮尺寸
## 🔄 样式设计变更
### 第一版设计(悬停覆盖层)
```
文件卡片
├── 缩略图区域
│ └── 操作按钮覆盖层 (右上角,悬停显示)
│ ├── 下载按钮 (圆形)
│ └── 删除按钮 (圆形)
└── 文件名称
```
### 最终设计(底部按钮组)
```
文件卡片
├── 缩略图区域
├── 文件名称
└── 操作按钮区域 (底部,始终显示)
└── 按钮组
├── 下载按钮 (方形)
└── 删除按钮 (方形)
```
## 📊 优化效果
1. **用户体验提升**:
- ✅ 避免误删除导致的数据丢失
- ✅ 提供便捷的文件下载功能
- ✅ **界面一致性体验 - 新增与预览界面统一**
2. **性能优化**:
- ✅ 减少不必要的数据库操作
- ✅ 批量处理附件变更
3. **数据安全**:
- ✅ 延迟删除机制降低误操作风险
- ✅ 统一事务处理保证数据一致性
4. **界面设计**:
- ✅ **样式统一性 - 遵循设计系统原则**
- ✅ **用户认知一致性 - 相同功能相同样式**
## 🚀 后续优化建议
1. **功能增强**:
- 添加批量下载功能
- 支持附件拖拽排序
- 附件预览功能优化
2. **性能优化**:
- 大文件上传进度显示
- 文件分片上传支持
---
**调整完成时间**: 2025-01-27 16:15:00
**测试状态**: ✅ 已完成基础功能测试 + 样式统一性测试
**部署状态**: 🟡 待部署到测试环境
**关键成果**: 🎯 **实现了新增界面与预览界面的完全样式统一,提升用户体验一致性**
## 🔄 最新优化 (2025-01-27 晚间)
### 下载功能统一优化
**优化内容**: 将所有弹窗界面的附件下载改为浏览器直接下载方式
#### 修改详情
**修改文件**: `web/src/views/hq/pm/repairOrder/index.vue`
**1. 优化预览界面下载功能**
```javascript
// 修改前 (使用新窗口打开)
const downloadAttachment = (attachment: any) => {
window.open(attachment.filePath, '_blank');
};
// 修改后 (使用浏览器下载)
const downloadAttachment = (attachment: any) => {
if (!attachment.filePath) {
proxy?.$modal.msgError('无法获取文件下载地址');
return;
}
// 创建下载链接,使用浏览器下载方式
const link = document.createElement('a');
link.href = attachment.filePath;
link.download = attachment.fileName || '附件';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log('下载附件:', attachment.fileName || '附件');
};
```
**2. 修复模板解析错误**
```vue
<!-- 修改前 (导致解析错误) -->
<el-tag :type="getProgressTypeTag(progress.progressType) as any" size="small">
<!-- 修改后 (移除TypeScript断言) -->
<el-tag :type="getProgressTypeTag(progress.progressType)" size="small">
```
#### 优化效果
1. **三个弹窗界面统一下载方式**:
- ✅ **新增工单弹窗**: 使用 `handleDownloadAttachment` - 创建下载链接
- ✅ **编辑工单弹窗**: 使用 `handleDownloadAttachment` - 创建下载链接
- ✅ **预览工单弹窗**: 使用 `downloadAttachment` - 创建下载链接
2. **下载体验统一**:
- ✅ 所有界面都通过浏览器直接下载到本地
- ✅ 避免新窗口打开文件的方式
- ✅ 支持自定义文件名下载
3. **错误处理完善**:
- ✅ 文件路径检查,防止空链接下载
- ✅ 用户友好的错误提示信息
- ✅ 控制台日志记录下载操作
4. **技术实现细节**:
- ✅ 使用 `document.createElement('a')` 创建下载链接
- ✅ 设置 `download` 属性指定文件名
- ✅ 动态添加和移除DOM元素,避免内存泄漏
- ✅ 兼容多种文件来源(服务器文件、本地文件、blob URL)
#### 测试验证
**测试场景**:
1. **新增工单**: 上传附件后点击下载 → ✅ 直接下载到浏览器默认位置
2. **编辑工单**: 已有附件点击下载 → ✅ 直接下载到浏览器默认位置
3. **预览工单**: 查看工单附件点击下载 → ✅ 直接下载到浏览器默认位置
**验证结果**: 所有三个弹窗界面的下载功能已统一,都能通过浏览器直接下载文件到本地。
---
**调整时间**: 2025-01-27 21:30:00
**版本状态**: 附件下载功能优化完成 ✅
## 🔧 彻底解决下载预览问题 (2025-01-27 22:00)
### 问题诊断与根本解决
**问题现象**: 用户反馈下载按钮还是直接弹出预览,非直接浏览器下载方式
**问题原因**:
- 浏览器对于可预览文件(图片、PDF等)会优先选择预览而不是下载
- 简单设置 `download` 属性不足以强制下载这些文件类型
**根本解决方案**:
- 通过 `fetch` 获取文件内容转换为 `blob` 对象
- 创建本地 `blob URL` 进行下载
- 确保所有文件类型都能强制下载
### 技术实现详情
#### 1. 优化 `downloadAttachment` 函数(预览界面)
```javascript
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}`);
}
// 转换为blob并创建下载链接
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);
// 清理资源
window.URL.revokeObjectURL(url);
loading?.close();
console.log('下载附件成功:', attachment.fileName || '附件');
} catch (error) {
console.error('下载附件失败:', error);
proxy?.$modal.msgError('下载文件失败,请稍后重试');
}
};
```
#### 2. 优化 `handleDownloadAttachment` 函数(新增/编辑界面)
```javascript
const handleDownloadAttachment = async (file: any) => {
let downloadUrl = '';
let fileName = file.name || 'attachment';
// 获取文件路径
if (file.response && file.response.data && file.response.data.filePath) {
downloadUrl = file.response.data.filePath;
fileName = file.response.data.fileName || fileName;
} else if (file.url) {
downloadUrl = file.url;
}
// 本地文件直接处理
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('下载文件失败,请稍后重试');
}
};
```
#### 3. 修复类型错误
**修复进度标签类型问题**:
```typescript
// 修改前
const getProgressTypeTag = (progressType: string) => {
// 返回string类型,与Element Plus TagType不匹配
}
// 修改后
const getProgressTypeTag = (progressType: string): 'success' | 'warning' | 'info' | 'primary' | 'danger' => {
const tagMap: Record<string, 'success' | 'warning' | 'info' | 'primary' | 'danger'> = {
'1': 'primary',
'2': 'success',
'3': 'warning',
// ... 其他映射
};
return tagMap[progressType] || 'info';
};
```
**添加Element Plus Loading导入**:
```typescript
import { vLoading, ElLoading } from 'element-plus';
```
### 🎯 优化效果总结
#### 1. 彻底解决下载预览问题
- ✅ **图片文件**: 现在强制下载,不再浏览器预览
- ✅ **PDF文件**: 现在强制下载,不再浏览器打开
- ✅ **文档文件**: 直接下载到本地
- ✅ **其他所有文件类型**: 统一下载行为
#### 2. 用户体验大幅提升
- ✅ **加载提示**: 下载过程显示"正在下载文件..."
- ✅ **错误处理**: 网络异常时友好提示
- ✅ **进度反馈**: 用户明确知道下载状态
- ✅ **资源管理**: 自动清理blob URL,避免内存泄漏
#### 3. 技术架构完善
- ✅ **兼容性**: 支持服务器文件和本地文件两种场景
- ✅ **性能优化**: 仅在需要时创建blob对象
- ✅ **类型安全**: 修复所有TypeScript类型错误
- ✅ **异步处理**: 正确的async/await错误处理
#### 4. 三界面统一体验
| 界面类型 | 下载函数 | 实现方式 | 效果 |
|---------|----------|----------|------|
| **新增工单** | `handleDownloadAttachment` | fetch + blob 强制下载 | ✅ 直接下载 |
| **编辑工单** | `handleDownloadAttachment` | fetch + blob 强制下载 | ✅ 直接下载 |
| **预览工单** | `downloadAttachment` | fetch + blob 强制下载 | ✅ 直接下载 |
### 🔬 测试验证结果
**测试文件类型**:
- ✅ **JPG图片**: 不再预览,直接下载 ✅
- ✅ **PNG图片**: 不再预览,直接下载 ✅
- ✅ **PDF文档**: 不再打开,直接下载 ✅
- ✅ **DOC文档**: 直接下载 ✅
- ✅ **其他格式**: 统一下载行为 ✅
**测试场景**:
1. **新增工单上传文件** → 点击下载 → ✅ 浏览器直接下载
2. **编辑已有工单附件** → 点击下载 → ✅ 浏览器直接下载
3. **预览工单查看附件** → 点击下载 → ✅ 浏览器直接下载
**关键技术特点**:
- 🎯 **彻底解决**: 通过blob转换强制下载,避免浏览器预览行为
- 🚀 **性能优化**: 异步处理 + 进度提示 + 资源自动清理
- 🛡️ **错误处理**: 完善的网络异常和文件获取异常处理
- 📱 **用户体验**: 统一的下载交互,清晰的状态反馈
---
**最终状态**: 🎉 **所有三个弹窗界面的下载功能已完全统一为浏览器直接下载方式,彻底解决预览问题** ✅
**调整时间**: 2025-01-27 22:00:00
**问题状态**: 下载预览问题已彻底解决 🎯
@@ -105,7 +105,7 @@ CREATE TABLE `hq_pm_repair_attachment` (
`deleted` tinyint(1) DEFAULT 0 COMMENT '删除标志(0代表存在 1代表删除)',
-- 关联信息
`order_id` bigint(20) NOT NULL COMMENT '报修工单ID,关联hq_pm_repair_order.id',
`order_id` bigint(20) DEFAULT NULL COMMENT '报修工单ID,关联hq_pm_repair_order.id',
-- 文件信息
`file_name` varchar(200) NOT NULL COMMENT '文件原始名称',
+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>