任务表单增加阶段选择框 增加审核意见功能 右侧展示优化

This commit is contained in:
jhd
2026-07-02 10:36:57 +08:00
parent bf7ee08b08
commit 5544056833
13 changed files with 690 additions and 50 deletions

View File

@@ -0,0 +1,35 @@
package com.ruoyi.rm.controller;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.rm.domain.bo.RmFileReviewBo;
import com.ruoyi.rm.domain.vo.RmFileReviewVo;
import com.ruoyi.rm.service.IRmFileReviewService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/rm/fileReview")
public class RmFileReviewController extends BaseController {
private final IRmFileReviewService fileReviewService;
@GetMapping("/list")
public R<List<RmFileReviewVo>> list(String fileModule, Long fileId) {
return R.ok(fileReviewService.queryByFile(fileModule, fileId));
}
@PostMapping
public R<RmFileReviewVo> add(@RequestBody RmFileReviewBo bo) {
return R.ok(fileReviewService.addReview(bo));
}
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.rm.domain.bo;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class RmFileReviewBo extends BaseEntity {
private Long id;
private Long fileId;
private String fileModule;
private Long reviewerId;
private String content;
private String reviewAction;
}

View File

@@ -0,0 +1,27 @@
package com.ruoyi.rm.domain.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("fad_rm_file_review")
public class RmFileReview extends BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId
private Long id;
private Long fileId;
private String fileModule;
private Long reviewerId;
private String content;
private String reviewAction;
@TableLogic
private Integer delFlag;
}

View File

@@ -0,0 +1,23 @@
package com.ruoyi.rm.domain.vo;
import com.ruoyi.common.core.domain.entity.SysUser;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
public class RmFileReviewVo implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private Long fileId;
private String fileModule;
private Long reviewerId;
private String content;
private String reviewAction;
private Date createTime;
/** 审核人姓名(从 sys_user 关联) */
private String reviewerName;
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.rm.mapper;
import com.ruoyi.common.core.mapper.BaseMapperPlus;
import com.ruoyi.rm.domain.entity.RmFileReview;
import com.ruoyi.rm.domain.vo.RmFileReviewVo;
public interface RmFileReviewMapper extends BaseMapperPlus<RmFileReviewMapper, RmFileReview, RmFileReviewVo> {
}

View File

@@ -0,0 +1,13 @@
package com.ruoyi.rm.service;
import com.ruoyi.rm.domain.bo.RmFileReviewBo;
import com.ruoyi.rm.domain.vo.RmFileReviewVo;
import java.util.List;
public interface IRmFileReviewService {
List<RmFileReviewVo> queryByFile(String fileModule, Long fileId);
RmFileReviewVo addReview(RmFileReviewBo bo);
}

View File

@@ -0,0 +1,58 @@
package com.ruoyi.rm.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.helper.LoginHelper;
import com.ruoyi.rm.domain.bo.RmFileReviewBo;
import com.ruoyi.rm.domain.entity.RmFileReview;
import com.ruoyi.rm.domain.vo.RmFileReviewVo;
import com.ruoyi.rm.mapper.RmFileReviewMapper;
import com.ruoyi.rm.service.IRmFileReviewService;
import com.ruoyi.system.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@RequiredArgsConstructor
@Service
public class RmFileReviewServiceImpl implements IRmFileReviewService {
private final RmFileReviewMapper baseMapper;
private final SysUserMapper sysUserMapper;
@Override
public List<RmFileReviewVo> queryByFile(String fileModule, Long fileId) {
List<RmFileReviewVo> list = baseMapper.selectVoList(
Wrappers.<RmFileReview>lambdaQuery()
.eq(RmFileReview::getFileModule, fileModule)
.eq(RmFileReview::getFileId, fileId)
.orderByAsc(RmFileReview::getCreateTime));
for (RmFileReviewVo vo : list) {
if (vo.getReviewerId() != null) {
SysUser user = sysUserMapper.selectUserById(vo.getReviewerId());
if (user != null) {
vo.setReviewerName(user.getNickName());
}
}
}
return list;
}
@Override
public RmFileReviewVo addReview(RmFileReviewBo bo) {
Long userId = LoginHelper.getUserId();
if (userId == null) {
throw new RuntimeException("未登录");
}
RmFileReview entity = BeanUtil.toBean(bo, RmFileReview.class);
entity.setReviewerId(userId);
baseMapper.insert(entity);
RmFileReviewVo vo = BeanUtil.toBean(entity, RmFileReviewVo.class);
SysUser user = sysUserMapper.selectUserById(userId);
vo.setReviewerName(user != null ? user.getNickName() : null);
return vo;
}
}

View File

@@ -122,3 +122,21 @@ export function delTask(taskId) {
method: 'delete'
})
}
// 按总包项目+阶段查询任务
export function listRmTask(projectId, stageCode) {
return request({
url: '/oa/task/rm/tasks',
method: 'get',
params: { projectId, stageCode }
})
}
// 按总包项目统计各阶段任务数
export function taskCountByStage(projectId) {
return request({
url: '/oa/task/rm/taskCount',
method: 'get',
params: { projectId }
})
}

View File

@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function listFileReview(fileModule, fileId) {
return request({
url: '/rm/fileReview/list',
method: 'get',
params: { fileModule, fileId }
})
}
export function addFileReview(data) {
return request({
url: '/rm/fileReview',
method: 'post',
data
})
}

View File

@@ -177,6 +177,26 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="阶段" prop="stageCode">
<el-select v-model="form.stageCode" placeholder="请选择阶段" clearable style="width:100%">
<el-option label="安装前准备" value="install_prep" />
<el-option label="布局图确定" value="layout" />
<el-option label="图纸详细设计" value="drawing_design" />
<el-option label="设备说明书" value="manual" />
<el-option label="技术审查" value="tech_review" />
<el-option label="预算" value="budget" />
<el-option label="采购管理" value="purchase" />
<el-option label="设备制造" value="manufacture" />
<el-option label="发货前清单" value="shipping" />
<el-option label="安装前准备详" value="install_prep_detail" />
<el-option label="安装问题反馈" value="install_feedback" />
<el-option label="安装后验收" value="acceptance" />
<el-option label="热负荷试车" value="hot_commissioning" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12" v-loading="trackLoading">
<el-form-item label="项目进度" prop="trackId">
<el-cascader v-if="trackOptions" v-model="form.trackId" :options="trackOptions" :props="cascaderProps"
@@ -631,7 +651,6 @@ export default {
this.userLoading = false;
});
},
/** 查询项目管理列表 */
getList () {
this.loading = true;
@@ -735,7 +754,8 @@ export default {
// 表单重置
reset () {
this.form = {
status: 0
status: 0,
stageCode: undefined
};
this.resetForm("form");
this.fileList = [];

View File

@@ -53,7 +53,7 @@
</div>
</div>
<!-- Right: detail + preview -->
<!-- Right: info + review + preview -->
<div class="right-panel">
<div class="right-header">
<span>{{ selected ? selected.drawingName : '选择图纸预览' }}</span>
@@ -62,23 +62,69 @@
<el-button size="small" icon="el-icon-edit" @click="handleEdit(selected)">编辑</el-button>
</div>
</div>
<!-- Info card -->
<div v-if="selected" class="detail-card">
<el-row :gutter="16" class="info-row">
<el-col :span="8"><span class="info-label">图号</span><span class="info-value">{{ selected.drawingNo || '-' }}</span></el-col>
<el-col :span="8"><span class="info-label">纸类型</span><span class="info-value">{{ selected.drawingType || '-' }}</span></el-col>
<el-col :span="8"><span class="info-label">版本</span><span class="info-value">{{ selected.version || '-' }}</span></el-col>
</el-row>
<el-row :gutter="16" class="info-row">
<el-col :span="8"><span class="info-label">设计</span><span class="info-value">{{ selected.drawer || '-' }}</span></el-col>
<el-col :span="8"><span class="info-label">设计日期</span><span class="info-value">{{ selected.startDate || '-' }}</span></el-col>
<el-col :span="8"><span class="info-label">完成日期</span><span class="info-value">{{ selected.endDate || '-' }}</span></el-col>
</el-row>
<el-row :gutter="16" class="info-row">
<el-col :span="8"><span class="info-label">状态</span><span class="info-value"><el-tag :type="statusTag(selected.status)" size="mini">{{ statusLabel(selected.status) }}</el-tag></span></el-col>
<el-col :span="16"><span class="info-label">备注</span><span class="info-value text-ellipsis" :title="selected.remark">{{ selected.remark || '-' }}</span></el-col>
</el-row>
</div>
<!-- Collapse: info + review -->
<el-collapse v-if="selected" v-model="collapseActive" class="info-collapse">
<el-collapse-item title="图纸信息" name="detail">
<div class="detail-grid">
<div class="detail-item"><span class="detail-label"></span><span class="detail-value">{{ selected.drawingNo || '-' }}</span></div>
<div class="detail-item"><span class="detail-label">图纸类型</span><span class="detail-value">{{ selected.drawingType || '-' }}</span></div>
<div class="detail-item"><span class="detail-label">版本</span><span class="detail-value">{{ selected.version || '-' }}</span></div>
<div class="detail-item"><span class="detail-label">设计人</span><span class="detail-value">{{ selected.drawer || '-' }}</span></div>
<div class="detail-item"><span class="detail-label">设计日期</span><span class="detail-value">{{ selected.startDate || '-' }}</span></div>
<div class="detail-item"><span class="detail-label">完成日期</span><span class="detail-value">{{ selected.endDate || '-' }}</span></div>
<div class="detail-item"><span class="detail-label">状态</span><span class="detail-value"><el-tag :type="statusTag(selected.status)" size="mini">{{ statusLabel(selected.status) }}</el-tag></span></div>
<div class="detail-item" v-if="selected.remark"><span class="detail-label">备注</span><span class="detail-value">{{ selected.remark }}</span></div>
</div>
</el-collapse-item>
<el-collapse-item name="review">
<template slot="title">
<span>审核意见 <el-tag v-if="fileReviews.length > 0" size="mini" style="margin-left:4px">{{ fileReviews.length }}</el-tag></span>
</template>
<el-button size="small" type="primary" icon="el-icon-edit" @click="openReviewDialog" style="margin-bottom:8px">发表意见</el-button>
<el-timeline v-if="fileReviews.length > 0">
<el-timeline-item
v-for="item in fileReviews"
:key="item.id"
:timestamp="item.createTime"
placement="top">
<div class="review-item">
<div class="review-item-header">
<strong>{{ item.reviewerName }}</strong>
<el-tag v-if="item.reviewAction" size="mini" :type="reviewActionTag(item.reviewAction)" class="review-action-tag">{{ item.reviewAction }}</el-tag>
</div>
<p class="review-item-content">{{ item.content }}</p>
</div>
</el-timeline-item>
</el-timeline>
<div v-else class="review-empty">
<i class="el-icon-chat-dot-round"></i>
<span>暂无审核意见</span>
</div>
</el-collapse-item>
<el-collapse-item name="tasks">
<template slot="title">
<span>关联任务 <el-tag v-if="rmTasks.length > 0" size="mini" style="margin-left:4px">{{ rmTasks.length }}</el-tag></span>
</template>
<div v-if="rmTasks.length > 0" class="task-list">
<div v-for="t in rmTasks" :key="t.taskId" class="task-item">
<div class="task-item-header">
<span class="task-item-title">{{ t.taskTitle }}</span>
<el-tag v-if="t.state === 2" size="mini" type="success">完成</el-tag>
<el-tag v-else-if="t.state === 1" size="mini" type="warning">待验收</el-tag>
<el-tag v-else size="mini" type="info">进行中</el-tag>
</div>
<div class="task-item-meta" v-if="t.workerNickName || t.createUserNickName">
<span v-if="t.workerNickName">执行人: {{ t.workerNickName }}</span>
<span v-if="t.createUserNickName">发起人: {{ t.createUserNickName }}</span>
</div>
</div>
</div>
<div v-else class="review-empty">
<i class="el-icon-s-management"></i>
<span>暂无关联任务</span>
</div>
</el-collapse-item>
</el-collapse>
<!-- Viewer -->
<div class="viewer-area" ref="viewerContainer">
<div v-if="!selected || !selected.fileUrl" class="viewer-placeholder">
@@ -89,6 +135,26 @@
</div>
</div>
<!-- Review Dialog -->
<el-dialog title="发表审核意见" :visible.sync="reviewDialogVisible" width="450px" append-to-body>
<el-form ref="reviewFormRef" :model="reviewForm" :rules="reviewRules" label-width="80px" size="small">
<el-form-item label="审核意见" prop="reviewAction">
<el-select v-model="reviewForm.reviewAction" placeholder="请选择" style="width:100%">
<el-option label="同意" value="同意" />
<el-option label="驳回" value="驳回" />
<el-option label="待定" value="待定" />
</el-select>
</el-form-item>
<el-form-item label="详细内容" prop="content">
<el-input v-model="reviewForm.content" type="textarea" :rows="4" placeholder="请输入审核意见" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button size="small" @click="reviewDialogVisible = false">取消</el-button>
<el-button size="small" type="primary" :loading="reviewSubmitting" @click="submitReview">提交</el-button>
</div>
</el-dialog>
<!-- Add/Edit Dialog -->
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="600px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" size="small">
@@ -185,6 +251,8 @@
<script>
import { listDrawingDesign, getDrawingDesign, addDrawingDesign, updateDrawingDesign, delDrawingDesign } from '@/api/rm/drawingDesign'
import { listProject } from '@/api/rm/project'
import { listFileReview, addFileReview } from '@/api/rm/fileReview'
import { listRmTask } from '@/api/oa/task'
import { getToken } from '@/utils/auth'
import { mountViewerFrame } from '@flyfish-group/file-viewer-web'
@@ -206,7 +274,16 @@ export default {
uploadUrl: process.env.VUE_APP_BASE_API + '/common/upload',
uploadHeaders: { Authorization: 'Bearer ' + getToken() },
selected: null,
viewerCtrl: null
viewerCtrl: null,
collapseActive: [],
fileReviews: [],
rmTasks: [],
reviewDialogVisible: false,
reviewForm: { reviewAction: '', content: '' },
reviewRules: {
reviewAction: [{ required: true, message: '请选择审核意见', trigger: 'change' }]
},
reviewSubmitting: false
}
},
created() { this.loadCurrentProject() },
@@ -214,12 +291,19 @@ export default {
this.destroyViewer()
},
methods: {
loadRmTasks(projectId) {
if (!projectId) return
listRmTask(projectId, 'drawing_design').then(res => {
this.rmTasks = res.data || []
})
},
loadCurrentProject() {
const pid = this.$route.query.projectId || sessionStorage.getItem('rm_current_project_id')
if (pid) {
this.currentProjectId = pid
this.query.projectId = pid
this.loadList()
this.loadRmTasks(pid)
} else {
listProject({ pageNum: 1, pageSize: 1 }).then(res => {
const rows = res.rows || []
@@ -227,6 +311,7 @@ export default {
this.currentProjectId = rows[0].projectId
this.query.projectId = rows[0].projectId
this.loadList()
this.loadRmTasks(rows[0].projectId)
}
})
}
@@ -247,6 +332,7 @@ export default {
this.$nextTick(() => {
this.mountPreview(row)
})
this.loadReviews('drawing_design', row.drawingId)
},
mountPreview(row) {
this.destroyViewer()
@@ -284,6 +370,7 @@ export default {
if (this.selected && this.selected.drawingId === row.drawingId) {
this.destroyViewer()
this.selected = null
this.fileReviews = []
}
this.loadList()
})
@@ -319,7 +406,37 @@ export default {
downloadFile(row) { if (row.fileUrl) window.open(row.fileUrl, '_blank') },
fileNameFromUrl(url) { if (!url) return ''; return decodeURIComponent(url.split('/').pop() || '') },
statusTag(s) { return { in_progress: 'info', completed: 'success', reviewed: 'primary' }[s] || 'info' },
statusLabel(s) { return { in_progress: '进行中', completed: '已完成', reviewed: '已审查' }[s] || s }
statusLabel(s) { return { in_progress: '进行中', completed: '已完成', reviewed: '已审查' }[s] || s },
loadReviews(fileModule, fileId) {
if (!fileId) return
listFileReview(fileModule, fileId).then(res => {
this.fileReviews = res.data || []
})
},
openReviewDialog() {
this.reviewForm = { reviewAction: '', content: '' }
this.reviewDialogVisible = true
this.$nextTick(() => { this.$refs.reviewFormRef?.clearValidate() })
},
submitReview() {
this.$refs.reviewFormRef.validate(valid => {
if (!valid) return
this.reviewSubmitting = true
addFileReview({
fileModule: 'drawing_design',
fileId: this.selected.drawingId,
content: this.reviewForm.content,
reviewAction: this.reviewForm.reviewAction
}).then(() => {
this.$message.success('提交成功')
this.reviewDialogVisible = false
this.loadReviews('drawing_design', this.selected.drawingId)
}).finally(() => { this.reviewSubmitting = false })
})
},
reviewActionTag(action) {
return { '同意': 'success', '驳回': 'danger', '待定': 'warning' }[action] || 'info'
}
}
}
</script>
@@ -340,12 +457,26 @@ export default {
.right-header { padding: 8px 12px; font-size: 14px; font-weight: 600; border-bottom: 1px solid #d0d7de; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.right-actions { display: flex; gap: 4px; }
/* Detail card */
.detail-card { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; }
.info-row { margin-bottom: 6px; }
.info-label { font-size: 11px; color: #909399; margin-right: 6px; }
.info-value { font-size: 12px; color: #333; }
.text-ellipsis { display: inline-block; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
/* Collapse info + review */
.info-collapse { flex-shrink: 0; }
.info-collapse >>> .el-collapse-item__header { padding: 0 12px; font-size: 13px; font-weight: 600; height: 36px; }
.info-collapse >>> .el-collapse-item__wrap { border-bottom: 1px solid #e8eaed; }
.info-collapse >>> .el-collapse-item__content { padding: 8px 12px 10px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; }
.detail-item { display: flex; font-size: 12px; line-height: 1.8; }
.detail-label { color: #909399; width: 55px; flex-shrink: 0; }
.detail-value { color: #333; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.review-item-header { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; }
.review-item-header strong { font-size: 13px; }
.review-item-content { font-size: 12px; color: #555; margin: 2px 0 0; line-height: 1.5; white-space: pre-wrap; }
.review-empty { text-align: center; color: #c0c4cc; font-size: 13px; padding: 8px 0; display: flex; flex-direction: column; align-items: center; gap: 4px; }
.review-action-tag { font-size: 11px; }
.info-collapse >>> .el-timeline-item__timestamp { font-size: 12px; color: #999; }
.task-list { display: flex; flex-direction: column; gap: 6px; }
.task-item { background: #f8f9fa; border-radius: 4px; padding: 6px 8px; }
.task-item-header { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.task-item-title { font-size: 12px; font-weight: 600; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.task-item-meta { font-size: 11px; color: #909399; margin-top: 2px; display: flex; gap: 8px; }
/* Viewer */
.viewer-area { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; position: relative; }

View File

@@ -48,7 +48,7 @@
@size-change="handleSizeChange" @current-change="handlePageChange" />
</div>
</div>
<!-- Right: preview -->
<!-- Right: details + review + preview -->
<div class="right-panel">
<div class="right-header">
<span>{{ selectedFile ? selectedFile.fileName : '选择文件预览' }}</span>
@@ -56,6 +56,88 @@
<el-button size="small" icon="el-icon-download" @click="downloadFile(selectedFile)">下载</el-button>
</div>
</div>
<!-- Collapse: info + review -->
<el-collapse v-if="selectedFile" v-model="collapseActive" class="info-collapse">
<el-collapse-item title="文件信息" name="detail">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">文件名</span>
<span class="detail-value">{{ selectedFile.fileName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">类型</span>
<span class="detail-value">{{ selectedFile.fileType }}</span>
</div>
<div class="detail-item">
<span class="detail-label">版本</span>
<span class="detail-value">{{ selectedFile.version || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">上传日期</span>
<span class="detail-value">{{ selectedFile.uploadDate || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">状态</span>
<span class="detail-value">
<el-tag :type="statusTag(selectedFile.status)" size="mini">{{ statusLabel(selectedFile.status) }}</el-tag>
</span>
</div>
<div class="detail-item" v-if="selectedFile.remark">
<span class="detail-label">备注</span>
<span class="detail-value">{{ selectedFile.remark }}</span>
</div>
</div>
</el-collapse-item>
<el-collapse-item name="review">
<template slot="title">
<span>审核意见 <el-tag v-if="fileReviews.length > 0" size="mini" style="margin-left:4px">{{ fileReviews.length }}</el-tag></span>
</template>
<el-button size="small" type="primary" icon="el-icon-edit" @click="openReviewDialog" style="margin-bottom:8px">发表意见</el-button>
<el-timeline v-if="fileReviews.length > 0">
<el-timeline-item
v-for="item in fileReviews"
:key="item.id"
:timestamp="item.createTime"
placement="top">
<div class="review-item">
<div class="review-item-header">
<strong>{{ item.reviewerName }}</strong>
<el-tag v-if="item.reviewAction" size="mini" :type="reviewActionTag(item.reviewAction)" class="review-action-tag">{{ item.reviewAction }}</el-tag>
</div>
<p class="review-item-content">{{ item.content }}</p>
</div>
</el-timeline-item>
</el-timeline>
<div v-else class="review-empty">
<i class="el-icon-chat-dot-round"></i>
<span>暂无审核意见</span>
</div>
</el-collapse-item>
<el-collapse-item name="tasks">
<template slot="title">
<span>关联任务 <el-tag v-if="rmTasks.length > 0" size="mini" style="margin-left:4px">{{ rmTasks.length }}</el-tag></span>
</template>
<div v-if="rmTasks.length > 0" class="task-list">
<div v-for="t in rmTasks" :key="t.taskId" class="task-item">
<div class="task-item-header">
<span class="task-item-title">{{ t.taskTitle }}</span>
<el-tag v-if="t.state === 2" size="mini" type="success">完成</el-tag>
<el-tag v-else-if="t.state === 1" size="mini" type="warning">待验收</el-tag>
<el-tag v-else size="mini" type="info">进行中</el-tag>
</div>
<div class="task-item-meta" v-if="t.workerNickName || t.createUserNickName">
<span v-if="t.workerNickName">执行人: {{ t.workerNickName }}</span>
<span v-if="t.createUserNickName">发起人: {{ t.createUserNickName }}</span>
</div>
</div>
</div>
<div v-else class="review-empty">
<i class="el-icon-s-management"></i>
<span>暂无关联任务</span>
</div>
</el-collapse-item>
</el-collapse>
<!-- Viewer -->
<div class="viewer-area" ref="viewerContainer">
<div v-if="!selectedFile || !selectedFile.fileUrl" class="viewer-placeholder">
<i class="el-icon-view" style="font-size:48px;color:#c0c4cc;"></i>
@@ -131,12 +213,34 @@
<el-button size="small" type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</div>
</el-dialog>
<!-- Review Dialog -->
<el-dialog title="发表审核意见" :visible.sync="reviewDialogVisible" width="450px" append-to-body>
<el-form ref="reviewFormRef" :model="reviewForm" :rules="reviewRules" label-width="80px" size="small">
<el-form-item label="审核意见" prop="reviewAction">
<el-select v-model="reviewForm.reviewAction" placeholder="请选择" style="width:100%">
<el-option label="同意" value="同意" />
<el-option label="驳回" value="驳回" />
<el-option label="待定" value="待定" />
</el-select>
</el-form-item>
<el-form-item label="详细内容" prop="content">
<el-input v-model="reviewForm.content" type="textarea" :rows="4" placeholder="请输入审核意见" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button size="small" @click="reviewDialogVisible = false">取消</el-button>
<el-button size="small" type="primary" :loading="reviewSubmitting" @click="submitReview">提交</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listLayoutFile, getLayoutFile, addLayoutFile, updateLayoutFile, delLayoutFile } from '@/api/rm/layoutFile'
import { listProject } from '@/api/rm/project'
import { listFileReview, addFileReview } from '@/api/rm/fileReview'
import { listRmTask } from '@/api/oa/task'
import { getToken } from '@/utils/auth'
import { mountViewerFrame } from '@flyfish-group/file-viewer-web'
@@ -158,7 +262,16 @@ export default {
uploadUrl: process.env.VUE_APP_BASE_API + '/common/upload',
uploadHeaders: { Authorization: 'Bearer ' + getToken() },
selectedFile: null,
viewerCtrl: null
viewerCtrl: null,
collapseActive: [],
fileReviews: [],
rmTasks: [],
reviewDialogVisible: false,
reviewForm: { reviewAction: '', content: '' },
reviewRules: {
reviewAction: [{ required: true, message: '请选择审核意见', trigger: 'change' }]
},
reviewSubmitting: false
}
},
created() { this.loadCurrentProject() },
@@ -172,6 +285,7 @@ export default {
this.currentProjectId = pid
this.query.projectId = pid
this.loadList()
this.loadRmTasks(pid)
} else {
listProject({ pageNum: 1, pageSize: 1 }).then(res => {
const rows = res.rows || []
@@ -179,10 +293,17 @@ export default {
this.currentProjectId = rows[0].projectId
this.query.projectId = rows[0].projectId
this.loadList()
this.loadRmTasks(rows[0].projectId)
}
})
}
},
loadRmTasks(projectId) {
if (!projectId) return
listRmTask(projectId, 'layout').then(res => {
this.rmTasks = res.data || []
})
},
loadList() {
this.loading = true
listLayoutFile(this.query).then(res => {
@@ -199,6 +320,13 @@ export default {
this.$nextTick(() => {
this.mountPreview(row)
})
this.loadReviews('layout', row.layoutFileId)
},
loadReviews(fileModule, fileId) {
if (!fileId) return
listFileReview(fileModule, fileId).then(res => {
this.fileReviews = res.data || []
})
},
mountPreview(row) {
this.destroyViewer()
@@ -236,6 +364,7 @@ export default {
if (this.selectedFile && this.selectedFile.layoutFileId === row.layoutFileId) {
this.destroyViewer()
this.selectedFile = null
this.fileReviews = []
}
this.loadList()
})
@@ -278,7 +407,31 @@ export default {
downloadFile(row) { if (row.fileUrl) window.open(row.fileUrl, '_blank') },
fileNameFromUrl(url) { if (!url) return ''; return decodeURIComponent(url.split('/').pop() || '') },
statusTag(s) { return { pending: 'info', approved: 'success' }[s] || 'info' },
statusLabel(s) { return { pending: '待审核', approved: '已批准' }[s] || s }
statusLabel(s) { return { pending: '待审核', approved: '已批准' }[s] || s },
openReviewDialog() {
this.reviewForm = { reviewAction: '', content: '' }
this.reviewDialogVisible = true
this.$nextTick(() => { this.$refs.reviewFormRef?.clearValidate() })
},
submitReview() {
this.$refs.reviewFormRef.validate(valid => {
if (!valid) return
this.reviewSubmitting = true
addFileReview({
fileModule: 'layout',
fileId: this.selectedFile.layoutFileId,
content: this.reviewForm.content,
reviewAction: this.reviewForm.reviewAction
}).then(() => {
this.$message.success('提交成功')
this.reviewDialogVisible = false
this.loadReviews('layout', this.selectedFile.layoutFileId)
}).finally(() => { this.reviewSubmitting = false })
})
},
reviewActionTag(action) {
return { '同意': 'success', '驳回': 'danger', '待定': 'warning' }[action] || 'info'
}
}
}
</script>
@@ -304,7 +457,30 @@ export default {
.right-panel { flex: 1; background: #fff; border-radius: 4px; border: 1px solid #d0d7de; display: flex; flex-direction: column; min-width: 0; }
.right-header { padding: 8px 12px; font-size: 14px; font-weight: 600; border-bottom: 1px solid #d0d7de; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.right-actions { display: flex; gap: 4px; }
.viewer-area { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; position: relative; }
/* Collapse info + review */
.info-collapse { flex-shrink: 0; }
.info-collapse >>> .el-collapse-item__header { padding: 0 12px; font-size: 13px; font-weight: 600; height: 36px; }
.info-collapse >>> .el-collapse-item__wrap { border-bottom: 1px solid #e8eaed; }
.info-collapse >>> .el-collapse-item__content { padding: 8px 12px 10px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; }
.detail-item { display: flex; font-size: 12px; line-height: 1.8; }
.detail-label { color: #909399; width: 65px; flex-shrink: 0; }
.detail-value { color: #333; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.review-item-header { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; }
.review-item-header strong { font-size: 13px; }
.review-item-content { font-size: 12px; color: #555; margin: 2px 0 0; line-height: 1.5; white-space: pre-wrap; }
.review-empty { text-align: center; color: #c0c4cc; font-size: 13px; padding: 8px 0; display: flex; flex-direction: column; align-items: center; gap: 4px; }
.review-action-tag { font-size: 11px; }
.info-collapse >>> .el-timeline-item__timestamp { font-size: 12px; color: #999; }
.task-list { display: flex; flex-direction: column; gap: 6px; }
.task-item { background: #f8f9fa; border-radius: 4px; padding: 6px 8px; }
.task-item-header { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.task-item-title { font-size: 12px; font-weight: 600; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.task-item-meta { font-size: 11px; color: #909399; margin-top: 2px; display: flex; gap: 8px; }
/* Viewer */
.viewer-area { flex: 1; min-height: 200px; display: flex; align-items: center; justify-content: center; overflow: hidden; position: relative; }
.viewer-area > iframe { width: 100%; height: 100%; border: 0; }
.viewer-placeholder { text-align: center; }

View File

@@ -42,7 +42,7 @@
</el-table>
</div>
<!-- Right: detail + preview -->
<!-- Right: info + review + preview -->
<div class="right-panel">
<div class="right-header">
<span>{{ selected ? selected.manualName : '选择文件预览' }}</span>
@@ -51,17 +51,42 @@
<el-button size="small" icon="el-icon-edit" @click="handleEdit(selected)">编辑</el-button>
</div>
</div>
<!-- Info card -->
<div v-if="selected" class="detail-card">
<el-row :gutter="16" class="info-row">
<el-col :span="8"><span class="info-label">类型</span><span class="info-value"><el-tag :type="tagType(selected.docType)" size="mini">{{ selected.docType }}</el-tag></span></el-col>
<el-col :span="8"><span class="info-label">版本</span><span class="info-value">{{ selected.version || '-' }}</span></el-col>
<el-col :span="8"><span class="info-label">上传日期</span><span class="info-value">{{ selected.uploadDate || '-' }}</span></el-col>
</el-row>
<el-row :gutter="16" class="info-row" v-if="selected.description">
<el-col :span="24"><span class="info-label">描述</span><span class="info-value">{{ selected.description }}</span></el-col>
</el-row>
</div>
<!-- Collapse: info + review -->
<el-collapse v-if="selected" v-model="collapseActive" class="info-collapse">
<el-collapse-item title="文件信息" name="detail">
<div class="detail-grid">
<div class="detail-item"><span class="detail-label">类型</span><span class="detail-value"><el-tag :type="tagType(selected.docType)" size="mini">{{ selected.docType }}</el-tag></span></div>
<div class="detail-item"><span class="detail-label">版本</span><span class="detail-value">{{ selected.version || '-' }}</span></div>
<div class="detail-item"><span class="detail-label">上传日期</span><span class="detail-value">{{ selected.uploadDate || '-' }}</span></div>
<div class="detail-item" v-if="selected.description"><span class="detail-label">描述</span><span class="detail-value">{{ selected.description }}</span></div>
</div>
</el-collapse-item>
<el-collapse-item name="review">
<template slot="title">
<span>审核意见 <el-tag v-if="fileReviews.length > 0" size="mini" style="margin-left:4px">{{ fileReviews.length }}</el-tag></span>
</template>
<el-button size="small" type="primary" icon="el-icon-edit" @click="openReviewDialog" style="margin-bottom:8px">发表意见</el-button>
<el-timeline v-if="fileReviews.length > 0">
<el-timeline-item
v-for="item in fileReviews"
:key="item.id"
:timestamp="item.createTime"
placement="top">
<div class="review-item">
<div class="review-item-header">
<strong>{{ item.reviewerName }}</strong>
<el-tag v-if="item.reviewAction" size="mini" :type="reviewActionTag(item.reviewAction)" class="review-action-tag">{{ item.reviewAction }}</el-tag>
</div>
<p class="review-item-content">{{ item.content }}</p>
</div>
</el-timeline-item>
</el-timeline>
<div v-else class="review-empty">
<i class="el-icon-chat-dot-round"></i>
<span>暂无审核意见</span>
</div>
</el-collapse-item>
</el-collapse>
<!-- Viewer -->
<div class="viewer-area" ref="viewerContainer">
<div v-if="!selected || !selected.fileUrl" class="viewer-placeholder">
@@ -72,6 +97,26 @@
</div>
</div>
<!-- Review Dialog -->
<el-dialog title="发表审核意见" :visible.sync="reviewDialogVisible" width="450px" append-to-body>
<el-form ref="reviewFormRef" :model="reviewForm" :rules="reviewRules" label-width="80px" size="small">
<el-form-item label="审核意见" prop="reviewAction">
<el-select v-model="reviewForm.reviewAction" placeholder="请选择" style="width:100%">
<el-option label="同意" value="同意" />
<el-option label="驳回" value="驳回" />
<el-option label="待定" value="待定" />
</el-select>
</el-form-item>
<el-form-item label="详细内容" prop="content">
<el-input v-model="reviewForm.content" type="textarea" :rows="4" placeholder="请输入审核意见" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button size="small" @click="reviewDialogVisible = false">取消</el-button>
<el-button size="small" type="primary" :loading="reviewSubmitting" @click="submitReview">提交</el-button>
</div>
</el-dialog>
<!-- Add/Edit Dialog -->
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px" append-to-body @closed="onClosed">
<el-form ref="form" :model="form" :rules="rules" label-width="0" size="small">
@@ -120,6 +165,8 @@
<script>
import { listManualAll, addManual, updateManual, delManual } from '@/api/rm/manual'
import { listProject } from '@/api/rm/project'
import { listFileReview, addFileReview } from '@/api/rm/fileReview'
import { listRmTask } from '@/api/oa/task'
import { getToken } from '@/utils/auth'
import { mountViewerFrame } from '@flyfish-group/file-viewer-web'
@@ -140,6 +187,15 @@ export default {
uploadHeaders: { Authorization: 'Bearer ' + getToken() },
selected: null,
viewerCtrl: null,
collapseActive: [],
fileReviews: [],
rmTasks: [],
reviewDialogVisible: false,
reviewForm: { reviewAction: '', content: '' },
reviewRules: {
reviewAction: [{ required: true, message: '请选择审核意见', trigger: 'change' }]
},
reviewSubmitting: false,
filterName: '',
filterType: ''
}
@@ -187,6 +243,7 @@ export default {
this.$nextTick(() => {
this.mountPreview(row)
})
this.loadReviews('manual', row.manualId)
},
mountPreview(row) {
this.destroyViewer()
@@ -226,6 +283,7 @@ export default {
if (this.selected && this.selected.manualId === row.manualId) {
this.destroyViewer()
this.selected = null
this.fileReviews = []
}
this.loadData()
})
@@ -260,7 +318,37 @@ export default {
},
handleUploadError() { this.$message.error('上传失败') },
downloadFile(url) { if (url) window.open(url, '_blank') },
fileNameFromUrl(url) { if (!url) return ''; return decodeURIComponent(url.split('/').pop() || '') }
fileNameFromUrl(url) { if (!url) return ''; return decodeURIComponent(url.split('/').pop() || '') },
loadReviews(fileModule, fileId) {
if (!fileId) return
listFileReview(fileModule, fileId).then(res => {
this.fileReviews = res.data || []
})
},
openReviewDialog() {
this.reviewForm = { reviewAction: '', content: '' }
this.reviewDialogVisible = true
this.$nextTick(() => { this.$refs.reviewFormRef?.clearValidate() })
},
submitReview() {
this.$refs.reviewFormRef.validate(valid => {
if (!valid) return
this.reviewSubmitting = true
addFileReview({
fileModule: 'manual',
fileId: this.selected.manualId,
content: this.reviewForm.content,
reviewAction: this.reviewForm.reviewAction
}).then(() => {
this.$message.success('提交成功')
this.reviewDialogVisible = false
this.loadReviews('manual', this.selected.manualId)
}).finally(() => { this.reviewSubmitting = false })
})
},
reviewActionTag(action) {
return { '同意': 'success', '驳回': 'danger', '待定': 'warning' }[action] || 'info'
}
}
}
</script>
@@ -286,11 +374,21 @@ export default {
.right-header { padding: 8px 12px; font-size: 14px; font-weight: 600; border-bottom: 1px solid #d0d7de; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.right-actions { display: flex; gap: 4px; }
/* Detail card */
.detail-card { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; }
.info-row { margin-bottom: 6px; }
.info-label { font-size: 11px; color: #909399; margin-right: 6px; }
.info-value { font-size: 12px; color: #333; }
/* Collapse info + review */
.info-collapse { flex-shrink: 0; }
.info-collapse >>> .el-collapse-item__header { padding: 0 12px; font-size: 13px; font-weight: 600; height: 36px; }
.info-collapse >>> .el-collapse-item__wrap { border-bottom: 1px solid #e8eaed; }
.info-collapse >>> .el-collapse-item__content { padding: 8px 12px 10px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; }
.detail-item { display: flex; font-size: 12px; line-height: 1.8; }
.detail-label { color: #909399; width: 65px; flex-shrink: 0; }
.detail-value { color: #333; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.review-item-header { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; }
.review-item-header strong { font-size: 13px; }
.review-item-content { font-size: 12px; color: #555; margin: 2px 0 0; line-height: 1.5; white-space: pre-wrap; }
.review-empty { text-align: center; color: #c0c4cc; font-size: 13px; padding: 8px 0; display: flex; flex-direction: column; align-items: center; gap: 4px; }
.review-action-tag { font-size: 11px; }
.info-collapse >>> .el-timeline-item__timestamp { font-size: 12px; color: #999; }
/* Viewer */
.viewer-area { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; position: relative; }