refactor(detail): 项目详情页改为只读, 移除所有增/删/编辑操作

按用户意图: 本页面 = 详情页 = 只读查看。

移除:
- 角色劳务: 行删除按钮 + + 增加角色按钮 + addRoleRow/removeRoleRowAt 函数
- 监督员: 行删除按钮 + + 增加监督员按钮 + openAddMonitor/confirmAddMonitor/removeMonitorAt + 候选人 dialog
- 已发布公告: 发布/刷新/删除按钮 + openUpload/submitAnnouncement/deleteAnnouncement + upload dialog
- 项目评价: 点击编辑 dialog + openScoreDialog/submitScore + score dialog
- 未使用的 state: scoreForm/scoreDims/totalScore/uploadForm/announceTypes/newMonitor/monitorCandidates/submitting

保留:
- 4 个区块只读展示 (项目信息 / 角色劳务 / 监督员 / 公告列表)
- 公告 Preview 组件 (查看附件)
- goBack / goEdit 导航 (供面包屑和后续编辑页跳转)
- loadProject / loadAnnouncements 数据加载

业务去向 (本任务范围外):
- 评分编辑 → /manager/projects/edit/:projectId (ProjectsNew.vue)
- 角色劳务 / 监督员编辑 → 同上
- 公告发布 → 后续需新增专门管理页

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-21 00:02:38 +08:00
co-authored by Claude
parent 99b9807270
commit 8ff87c6845
@@ -1,11 +1,11 @@
<template> <template>
<div class="page-card"> <div class="page-card">
<!-- 面包屑 (与列表页一致) --> <!-- 面包屑 -->
<div class="breadcrumb"> <div class="breadcrumb">
首页 / 项目管理 / <span class="current">项目详情</span> 首页 / 项目管理 / <span class="current">项目详情</span>
</div> </div>
<!-- ================= 1. 项目信息 (11 字段, 两列) ================= --> <!-- ================= 1. 项目信息 (11 字段, 两列, 只读) ================= -->
<div class="new-card-title">项目信息</div> <div class="new-card-title">项目信息</div>
<el-form v-if="row && row.projectId" label-width="120px" class="info-form"> <el-form v-if="row && row.projectId" label-width="120px" class="info-form">
<el-row :gutter="12"> <el-row :gutter="12">
@@ -26,141 +26,70 @@
</el-row> </el-row>
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="12"><el-form-item label="是否招标项目"><span class="info-value">{{ row.isBidProject === 'Y' ? '是' : '否' }}</span></el-form-item></el-col> <el-col :span="12"><el-form-item label="是否招标项目"><span class="info-value">{{ row.isBidProject === 'Y' ? '是' : '否' }}</span></el-form-item></el-col>
<el-col :span="12"><el-form-item label="项目负责人"><span class="info-value">{{ row.leadUserName || row.sponsorAdminUserName || '-' }}</span></el-form-item></el-col> <el-col :span="12"><el-form-item label="项目负责人"><span class="info-value">{{ row.leadUserName || '-' }}</span></el-form-item></el-col>
</el-row> </el-row>
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="12"><el-form-item label="支持公司"><span class="info-value">{{ row.sponsorAdminUserName || '-' }}</span></el-form-item></el-col> <el-col :span="12"><el-form-item label="支持公司"><span class="info-value">{{ row.sponsorAdminUserName || '-' }}</span></el-form-item></el-col>
<el-col :span="12"><el-form-item label="项目评价"><a class="op-link" href="javascript:void(0)" @click="openScoreDialog">{{ fmtScore(row.managerScore) }}</a></el-form-item></el-col> <el-col :span="12"><el-form-item label="项目评价"><span class="info-value">{{ fmtScore(row.managerScore) }}</span></el-form-item></el-col>
</el-row> </el-row>
</el-form> </el-form>
<!-- ================= 2. 角色劳务设置 ================= --> <!-- ================= 2. 角色劳务设置 (只读) ================= -->
<div class="new-card-title">角色劳务设置</div> <div class="new-card-title">角色劳务设置</div>
<p class="hint-text">*角色劳务设置针对该项目所有会议生效</p> <p class="hint-text">*角色劳务设置针对该项目所有会议生效</p>
<table class="info-table role-labor-table"> <table class="info-table role-labor-table">
<colgroup> <colgroup>
<col style="width:120px"> <col style="width:120px">
<col style="width:180px"> <col style="width:180px">
<col style="width:80px">
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th>角色</th> <th>角色</th>
<th style="text-align:right">劳务金额</th> <th style="text-align:right">劳务金额</th>
<th style="text-align:center">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(r, idx) in form.roleRows" :key="idx"> <tr v-for="(r, idx) in form.roleRows" :key="idx">
<td>{{ r.role || '-' }}</td> <td>{{ r.role || '-' }}</td>
<td style="text-align:right">¥ {{ fmtMoney(r.amount) }}</td> <td style="text-align:right">¥ {{ fmtMoney(r.amount) }}</td>
<td style="text-align:center">
<el-button link type="danger" :disabled="form.roleRows.length<=1" @click="removeRoleRowAt(idx)">删除</el-button>
</td>
</tr> </tr>
<tr v-if="!form.roleRows.length"><td colspan="2" class="empty-row">暂无角色</td></tr>
</tbody> </tbody>
</table> </table>
<div style="margin-top:8px">
<el-button link type="primary" @click="addRoleRow">+ 增加角色</el-button>
</div>
<!-- ================= 3. 支持单位监督员 ================= --> <!-- ================= 3. 支持单位监督员 (只读) ================= -->
<div class="new-card-title">支持单位监督员</div> <div class="new-card-title">支持单位监督员</div>
<p class="hint-text">*增加只显示已点支持意向的</p> <p class="hint-text">*增加只显示已点支持意向的</p>
<div class="monitor-list"> <div class="monitor-list">
<div v-for="(m, idx) in monitors" :key="idx" class="monitor-row"> <div v-for="(m, idx) in monitors" :key="idx" class="monitor-row">
<span class="monitor-num">{{ idx + 1 }}.</span> <span class="monitor-num">{{ idx + 1 }}.</span>
<span class="monitor-name">{{ m.userName || m.name || '-' }}</span> <span class="monitor-name">{{ m.userName || m.name || '-' }}</span>
<el-button link type="danger" @click="removeMonitorAt(idx)">删除</el-button>
</div> </div>
<div v-if="!monitors.length" class="empty-hint">暂无监督员</div> <div v-if="!monitors.length" class="empty-hint">暂无监督员</div>
</div> </div>
<div style="margin-top:8px">
<el-button link type="primary" @click="openAddMonitor">+ 增加监督员</el-button>
</div>
<!-- ================= 4. 已发布公告 ================= --> <!-- ================= 4. 已发布公告 (只读, 仅查看) ================= -->
<div class="new-card-title">已发布公告</div> <div class="new-card-title">已发布公告</div>
<div class="toolbar">
<el-button type="primary" @click="openUpload">发布公告</el-button>
<el-button @click="loadAnnouncements">刷新</el-button>
</div>
<el-table v-loading="loading" :data="announcements" border> <el-table v-loading="loading" :data="announcements" border>
<el-table-column prop="title" label="公告标题" min-width="200" show-overflow-tooltip /> <el-table-column prop="title" label="公告标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="createTime" label="发布时间" width="160" align="center" :formatter="(r)=>fmtDate(r.createTime)" /> <el-table-column prop="createTime" label="发布时间" width="160" align="center" :formatter="(r)=>fmtDate(r.createTime)" />
<el-table-column prop="creatorName" label="发布人" width="120" align="center" /> <el-table-column prop="creatorName" label="发布人" width="120" align="center" />
<el-table-column label="操作" width="140" align="center"> <el-table-column label="操作" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="openPreview(row.fileUrl || row.url, row.title)">查看</el-button> <el-button link type="primary" @click="openPreview(row.fileUrl || row.url, row.title)">查看</el-button>
<el-button link type="danger" @click="deleteAnnouncement(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<!-- ================= dialogs ================= --> <!-- 公告预览 (只读) -->
<!-- 发布公告 dialog -->
<el-dialog v-model="uploadOpen" title="发布公告" width="560px" @closed="resetUploadForm">
<el-form :model="uploadForm" label-width="100px">
<el-form-item label="公告类型">
<el-select v-model="uploadForm.announceType" placeholder="请选择" style="width:100%">
<el-option v-for="t in announceTypes" :key="t.key" :label="t.label" :value="t.key" />
</el-select>
</el-form-item>
<el-form-item label="公告标题">
<el-input v-model="uploadForm.title" placeholder="请输入公告标题" maxlength="80" show-word-limit />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="uploadForm.remark" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadOpen=false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitAnnouncement">发布</el-button>
</template>
</el-dialog>
<!-- 评分 dialog -->
<el-dialog v-model="scoreOpen" title="项目评分" width="560px">
<el-form :model="scoreForm" label-width="120px">
<el-form-item v-for="d in scoreDims" :key="d.key" :label="d.label">
<el-rate v-model="scoreForm[d.key]" :max="5" show-score />
</el-form-item>
<el-form-item label="总分">
<span class="score-num">{{ totalScore }}</span> / 20
</el-form-item>
</el-form>
<template #footer>
<el-button @click="scoreOpen=false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitScore">保存评分</el-button>
</template>
</el-dialog>
<!-- 增加监督员 dialog -->
<el-dialog v-model="addMonitorOpen" title="增加监督员" width="420px">
<el-form label-width="100px">
<el-form-item label="监督员">
<el-select v-model="newMonitor.userId" placeholder="请选择监督员" filterable style="width:100%">
<el-option v-for="u in monitorCandidates" :key="u.userId"
:label="`${u.userName}${u.nickName ? ' (' + u.nickName + ')' : ''}`" :value="u.userId" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addMonitorOpen=false">取消</el-button>
<el-button type="primary" @click="confirmAddMonitor">确定</el-button>
</template>
</el-dialog>
<!-- 公告预览 -->
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" /> <Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { bizGet, bizList } from '@/api/public'
import { bizGet, bizList, bizAdd, bizUpdate, bizDelete } from '@/api/public'
import Preview from '@/components/Preview.vue' import Preview from '@/components/Preview.vue'
const route = useRoute() const route = useRoute()
@@ -170,42 +99,21 @@ const router = useRouter()
const projectId = ref('') const projectId = ref('')
const row = ref({}) const row = ref({})
const loading = ref(false) const loading = ref(false)
const submitting = ref(false)
const announceTypes = [
{ key: 'invitation', label: '邀请函' },
{ key: 'support', label: '支持函' },
{ key: 'notice', label: '通知' },
{ key: 'agenda', label: '日程' }
]
// 角色劳务 (从 biz_project.role_labor JSON 字段读)
const form = reactive({ const form = reactive({
roleRows: [ roleRows: [
{ role: '主席', customName: '', amount: 5000 }, { role: '主席', customName: '', amount: 5000 },
{ role: '主持', customName: '', amount: 3000 } { role: '主持', customName: '', amount: 3000 }
] ]
}) })
// 监督员 (从 biz_project.monitors JSON 字段读)
const monitors = ref([]) const monitors = ref([])
const addMonitorOpen = ref(false)
const newMonitor = reactive({ userId: null })
const monitorCandidates = ref([])
// 已发布公告
const announcements = ref([]) const announcements = ref([])
const uploadOpen = ref(false)
const uploadForm = reactive({ announceType: 'notice', title: '', remark: '' })
const scoreOpen = ref(false)
const scoreForm = reactive({ scoreQuality: 0, scoreSpeed: 0, scoreCoordination: 0, scoreCompliance: 0 })
const scoreDims = [
{ key: 'scoreQuality', label: '履约质量' },
{ key: 'scoreSpeed', label: '时效响应' },
{ key: 'scoreCoordination', label: '配合度' },
{ key: 'scoreCompliance', label: '合规安全' }
]
const totalScore = computed(() =>
scoreForm.scoreQuality + scoreForm.scoreSpeed + scoreForm.scoreCoordination + scoreForm.scoreCompliance
)
// 公告预览
const previewOpen = ref(false) const previewOpen = ref(false)
const previewUrl = ref('') const previewUrl = ref('')
const previewTitle = ref('公告预览') const previewTitle = ref('公告预览')
@@ -237,13 +145,8 @@ async function loadProject() {
loading.value = true loading.value = true
try { try {
const resp = await bizGet('project', projectId.value) const resp = await bizGet('project', projectId.value)
// 后端 success(BizProject) 返回 {code, msg, data: BizProject}
const data = (resp && resp.data) || resp || {} const data = (resp && resp.data) || resp || {}
row.value = data || {} row.value = data || {}
scoreForm.scoreQuality = numOrZero(row.value.scoreQuality)
scoreForm.scoreSpeed = numOrZero(row.value.scoreSpeed)
scoreForm.scoreCoordination = numOrZero(row.value.scoreCoordination)
scoreForm.scoreCompliance = numOrZero(row.value.scoreCompliance)
// 角色劳务 (JSON 字段, 缺则用默认) // 角色劳务 (JSON 字段, 缺则用默认)
try { try {
const rl = JSON.parse(row.value.roleLabor || '[]') const rl = JSON.parse(row.value.roleLabor || '[]')
@@ -273,120 +176,13 @@ async function loadAnnouncements() {
} }
} }
// ===================== 角色劳务 =====================
function addRoleRow() {
if (form.roleRows.length >= 20) { ElMessage.warning('最多 20 行角色'); return }
form.roleRows.push({ role: '', customName: '', amount: 0 })
}
function removeRoleRowAt(idx) {
if (form.roleRows.length <= 1) { ElMessage.warning('至少保留 1 行角色'); return }
form.roleRows.splice(idx, 1)
}
// ===================== 监督员 =====================
async function openAddMonitor() {
addMonitorOpen.value = true
if (!monitorCandidates.value.length) {
try {
const resp = await bizList('person', { pageNum: 1, pageSize: 100, role: 'supervisor', unitType: 'sponsor' })
monitorCandidates.value = (resp && resp.rows) || ((resp && resp.data) && (resp && resp.data).rows) || (resp && resp.data) || []
} catch (e) { monitorCandidates.value = [] }
}
}
function confirmAddMonitor() {
if (!newMonitor.userId) { ElMessage.warning('请选择监督员'); return }
const u = monitorCandidates.value.find(x => x.userId === newMonitor.userId)
if (u) monitors.value.push({ userId: u.userId, userName: u.userName, name: u.nickName || u.userName })
addMonitorOpen.value = false
newMonitor.userId = null
}
function removeMonitorAt(idx) {
if (monitors.value.length <= 1) { ElMessage.warning('至少保留 1 行监督员'); return }
monitors.value.splice(idx, 1)
}
// ===================== 公告 =====================
function openUpload() {
resetUploadForm()
uploadOpen.value = true
}
function resetUploadForm() {
uploadForm.announceType = 'notice'
uploadForm.title = ''
uploadForm.remark = ''
}
async function submitAnnouncement() {
if (!uploadForm.title) { ElMessage.warning('请输入公告标题'); return }
submitting.value = true
try {
await bizAdd('publicity', {
projectId: projectId.value,
announceType: uploadForm.announceType,
title: uploadForm.title,
remark: uploadForm.remark,
status: '1'
})
ElMessage.success('已发布')
uploadOpen.value = false
await loadAnnouncements()
} catch (e) {
console.error(e)
ElMessage.error('发布失败')
} finally {
submitting.value = false
}
}
async function deleteAnnouncement(a) {
try {
await ElMessageBox.confirm(`确认删除公告【${a.title || '未命名'}】?`, '提示', { type: 'warning' })
await bizDelete('publicity', a.id || a.publicityId)
ElMessage.success('已删除')
await loadAnnouncements()
} catch (e) {
if (e !== 'cancel') console.error(e)
}
}
// ===================== 评分 =====================
function openScoreDialog() {
scoreOpen.value = true
}
async function submitScore() {
if (totalScore.value < 4) {
return ElMessage.warning('请至少给每个维度 1 分')
}
submitting.value = true
try {
await request({
url: '/business/project/rate',
method: 'post',
data: {
projectId: projectId.value,
raterRole: 'compliance',
qualityScore: scoreForm.scoreQuality,
responseScore: scoreForm.scoreSpeed,
cooperationScore: scoreForm.scoreCoordination,
complianceScore: scoreForm.scoreCompliance,
}
})
ElMessage.success('评分已保存')
scoreOpen.value = false
await loadProject()
} catch (e) {
console.error(e)
ElMessage.error('保存失败')
} finally {
submitting.value = false
}
}
// ===================== 导航 ===================== // ===================== 导航 =====================
function goBack() { router.push('/manager/projects') } function goBack() { router.push('/manager/projects') }
function goEdit() { router.push(`/manager/projects/edit/${projectId.value}`) } function goEdit() { router.push(`/manager/projects/edit/${projectId.value}`) }
// ===================== 预览 ===================== // ===================== 预览 =====================
function openPreview(url, title) { function openPreview(url, title) {
if (!url) { ElMessage.warning('暂无附件'); return } if (!url) return
previewUrl.value = url previewUrl.value = url
previewTitle.value = title || '公告预览' previewTitle.value = title || '公告预览'
previewOpen.value = true previewOpen.value = true
@@ -405,12 +201,6 @@ onMounted(async () => {
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; } .breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.breadcrumb .current { color: #262626; font-weight: 500; } .breadcrumb .current { color: #262626; font-weight: 500; }
/* 项目信息: 11 字段, 一列竖排 (el-form + 文本, 无分隔线) */
.info-form { padding-top: 4px; }
.info-form :deep(.el-form-item) { margin-bottom: 8px; }
.info-form :deep(.el-form-item__label) {
font-size: 14px; color: #595959; width: 130px; padding-right: 16px;
}
/* 章节标题 (与编辑页 ProjectsNew.vue 保持一致) */ /* 章节标题 (与编辑页 ProjectsNew.vue 保持一致) */
.new-card-title { .new-card-title {
font-size: 14px; font-weight: 600; color: #262626; font-size: 14px; font-weight: 600; color: #262626;
@@ -418,15 +208,17 @@ onMounted(async () => {
border-left: 3px solid var(--brand-primary); line-height: 1; border-left: 3px solid var(--brand-primary); line-height: 1;
} }
.new-card-title:first-child { margin-top: 0; } .new-card-title:first-child { margin-top: 0; }
/* 项目信息: 11 字段, 一列竖排 (el-form + 文本, 无分隔线) */
.info-form { padding-top: 4px; }
.info-form :deep(.el-form-item) { margin-bottom: 8px; }
.info-form :deep(.el-form-item__label) {
font-size: 14px; color: #595959; width: 130px; padding-right: 16px;
}
.info-value { font-size: 15px; color: #1a1a1a; font-weight: 500; } .info-value { font-size: 15px; color: #1a1a1a; font-weight: 500; }
.info-value.money { .info-value.money {
font-family: ui-monospace, "Courier New", monospace; font-family: ui-monospace, "Courier New", monospace;
} }
.op-link {
color: var(--brand-primary); text-decoration: none;
font-weight: 600; cursor: pointer; font-size: 15px;
}
.op-link:hover { text-decoration: underline; }
/* 监督员 */ /* 监督员 */
.monitor-list { padding: 4px 0; } .monitor-list { padding: 4px 0; }
@@ -448,15 +240,12 @@ onMounted(async () => {
.role-labor-table td:last-child { border-right: none; } .role-labor-table td:last-child { border-right: none; }
.info-table th { background: #fafafa; padding: 10px 12px; text-align: left; color: #1a1a1a; font-weight: 600; border-bottom: 1px solid #f0f0f0; white-space: nowrap; } .info-table th { background: #fafafa; padding: 10px 12px; text-align: left; color: #1a1a1a; font-weight: 600; border-bottom: 1px solid #f0f0f0; white-space: nowrap; }
.info-table td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; color: #595959; vertical-align: middle; } .info-table td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; color: #595959; vertical-align: middle; }
.empty-row { text-align: center; color: #c0c4cc; }
/* 提示 (灰色, 跟 ProjectsNew 一致) */ /* 提示 (灰色, 跟 ProjectsNew 一致) */
.hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; } .hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; }
.hint-text p { margin-bottom: 4px; } .hint-text p { margin-bottom: 4px; }
.hint-text p:last-child { margin-bottom: 0; } .hint-text p:last-child { margin-bottom: 0; }
/* 工具栏 */
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
/* 评分 */
.score-num { font-size: 18px; color: var(--brand-primary); font-weight: 700; margin-right: 6px; }
</style> </style>
</content>
</invoke>