OA ecology: - BizEcologyController + EcologyProjectService: 项目编号搜索/详情回填, 负责人静默建档 - ComplianceLoginService: 合规/负责人登录校验 + 视图 role 匹配 - application-prod/test.yml: ecology 只读库数据源 会议邀请函/分享: - InvitationPdfService + WxShareService: 邀请函 PDF 生成 + 微信分享 - PdfViewer.vue (pdfjs-dist) + MeetingInvitation.vue: PDF 预览 + 邀请函页 参会人/执行意向: - BizMeetingAttendee*: 参会人模块增强 - BizExecutionIntent*: 执行意向增强 签到/劳务/导入: - BizSignServiceImpl + SignFill.vue: 签到劳务 - FieldMismatch/ImportMismatch: 导入校验 VO 公示/会议详情: - PublicityDetail.vue / MeetingDetail.vue 等前端增强 配置: - test 短信 mock + 供应商/招标接口切生产域名
951 lines
48 KiB
Vue
951 lines
48 KiB
Vue
<template>
|
||
<div class="page-card">
|
||
<div class="breadcrumb">首页 / {{ isLeader ? '项目列表' : '项目管理' }}</div>
|
||
|
||
<el-form inline :model="q" class="filter-form">
|
||
<el-form-item label="支持单位:">
|
||
<el-select
|
||
v-model="q.sponsorOrgIds"
|
||
multiple
|
||
filterable
|
||
remote
|
||
:remote-method="searchSponsorOrgsForFilter"
|
||
:loading="sponsorSearching"
|
||
clearable
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
placeholder="输入支持单位名称搜索"
|
||
style="width:260px"
|
||
>
|
||
<el-option v-for="o in sponsorOrgOptions" :key="o.orgId" :label="o.orgName" :value="o.orgId" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="服务机构:">
|
||
<el-select
|
||
v-model="q.execOrgIds"
|
||
multiple
|
||
filterable
|
||
remote
|
||
:remote-method="searchExecutorOrgsForFilter"
|
||
:loading="execSearching"
|
||
clearable
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
placeholder="输入服务机构名称搜索"
|
||
style="width:260px"
|
||
>
|
||
<el-option v-for="o in executorOrgOptions" :key="o.orgId" :label="o.orgName" :value="o.orgId" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="项目编号:"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
|
||
<el-form-item label="项目名称:"><el-input v-model="q.projectName" placeholder="输入项目名称" clearable /></el-form-item>
|
||
<el-form-item label="项目形式:">
|
||
<el-select v-model="q.projectForm" placeholder="请选择" clearable style="width:140px">
|
||
<el-option label="线上" value="线上" /><el-option label="线下" value="线下" />
|
||
<el-option label="线上+线下" value="线上+线下" /><el-option label="其他" value="其他" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="项目时间:">
|
||
<el-date-picker v-model="q.startTime" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" />
|
||
<span style="margin:0 4px;">至</span>
|
||
<el-date-picker v-model="q.endTime" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" />
|
||
</el-form-item>
|
||
<el-form-item label="是否结题:">
|
||
<el-select v-model="q.isFinished" placeholder="请选择" clearable style="width:120px">
|
||
<el-option label="已结题" value="1" /><el-option label="未结题" value="0" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="是否结算:">
|
||
<el-select v-model="q.isSettled" placeholder="请选择" clearable style="width:120px">
|
||
<el-option label="已结算" value="Y" /><el-option label="未结算" value="N" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="load">查询</el-button>
|
||
<el-button @click="reset">重置</el-button>
|
||
<el-button v-if="!isLeader" @click="doExport">导出</el-button>
|
||
<el-button v-if="!isLeader" @click="doExportRating">项目评价导出</el-button>
|
||
<el-button v-if="isManager || isAdmin" @click="doExportSignupExpert">已报名专家导出</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<div v-if="!isLeader" class="toolbar">
|
||
<el-button class="action-btn" @click="openNewProject">新建项目</el-button>
|
||
<el-button @click="openAssign">项目分配</el-button>
|
||
<el-button @click="openBatch('score')">批量评分</el-button>
|
||
<el-button @click="openBatch('close')">批量结题</el-button>
|
||
<el-button @click="openBatch('activate')">批量开通</el-button>
|
||
<el-button v-if="canDelete" class="action-btn secondary" type="danger" :disabled="!selection.length" @click="onBatchDelete">
|
||
批量删除<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||
</el-button>
|
||
</div>
|
||
<GrTable
|
||
:data="rows"
|
||
v-loading="loading"
|
||
stripe
|
||
border
|
||
:main-cols="['projectNo', 'projectName']"
|
||
@selection-change="onSel"
|
||
>
|
||
<el-table-column v-if="!isLeader" type="selection" width="44" />
|
||
<el-table-column prop="projectNo" label="项目编号" width="170" fixed="left" />
|
||
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<el-link :underline="false" type="primary" @click="viewDetail(row)">{{ row.projectName }}</el-link>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="totalSessions" label="总场次/总期数" width="130" align="center">
|
||
<template #default="{ row }">
|
||
<el-link v-if="row.totalSessions" :underline="false" type="primary" @click="goMeetings(row, 'all')">{{ row.totalSessions }}</el-link>
|
||
<span v-else style="color:#c0c4cc">0</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center">
|
||
<template #default="{ row }">
|
||
<el-link v-if="row.doneSessions" :underline="false" type="primary" @click="goMeetings(row, 'done')">{{ row.doneSessions }}</el-link>
|
||
<span v-else style="color:#c0c4cc">0</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center">
|
||
<template #default="{ row }">
|
||
<el-link v-if="row.todoSessions" :underline="false" type="primary" @click="goMeetings(row, 'todo')">{{ row.todoSessions }}</el-link>
|
||
<span v-else style="color:#c0c4cc">0</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="totalAmount" label="总金额" width="130" align="right" :formatter="fmtMoney" />
|
||
<el-table-column prop="availableAmount" label="可用金额" width="130" align="right" :formatter="fmtMoney" />
|
||
<el-table-column prop="paidLaborAmount" label="已支付劳务费" width="140" align="right" :formatter="fmtMoney" />
|
||
<el-table-column prop="paidMeetingAmount" label="已支付会务费" width="140" align="right" :formatter="fmtMoney" />
|
||
<el-table-column prop="managerScore" label="执行单位评分(合规)" width="170" align="center">
|
||
<template #default="{ row }">
|
||
<el-link v-if="row.managerScore != null" :underline="false" type="primary" @click="openScoreDetail(row, 'manager')">{{ row.managerScore }}</el-link>
|
||
<span v-else style="color:#c0c4cc">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="sponsorScore" label="执行单位评分(支持)" width="170" align="center">
|
||
<template #default="{ row }">
|
||
<el-link v-if="row.sponsorScore != null" :underline="false" type="primary" @click="openScoreDetail(row, 'sponsor')">{{ row.sponsorScore }}</el-link>
|
||
<span v-else style="color:#c0c4cc">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="sponsorOrgName" label="支持单位" width="160" show-overflow-tooltip />
|
||
<el-table-column prop="execOrgNames" label="服务机构" min-width="180" show-overflow-tooltip />
|
||
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
|
||
<el-table-column prop="isFinished" label="是否结题" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.isFinished === '1' || row.isFinished === 1" type="success">已结题</el-tag>
|
||
<el-tag v-else type="warning">未结题</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="startTime" label="项目开始时间" width="170" align="center" :formatter="fmtDateTime" />
|
||
<el-table-column prop="endTime" label="项目结束时间" width="170" align="center" :formatter="fmtDateTime" />
|
||
<el-table-column label="操作" :width="isLeader ? 80 : 180" fixed="right">
|
||
<template #default="{ row }"><div class="table-actions">
|
||
<div class="op-cell">
|
||
<el-link v-if="isLeader" :underline="false" type="primary" @click="viewDetail(row)">查看</el-link>
|
||
<template v-else>
|
||
<el-link v-if="!isFinishedRow(row)" :underline="false" type="primary" @click="doCreateMeeting(row)">建会</el-link>
|
||
<el-link :underline="false" type="primary" :disabled="isFinishedRow(row)" @click="doAssign(row)">分配</el-link>
|
||
<el-dropdown trigger="hover" @command="(cmd) => onAction(cmd, row)">
|
||
<el-link :underline="false" type="primary" class="op-dropdown">
|
||
更多<el-icon class="op-caret"><ArrowDown /></el-icon>
|
||
</el-link>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item v-if="row.isFinished !== '1' && row.isFinished !== 1" command="close">结题</el-dropdown-item>
|
||
<el-dropdown-item command="view">查看</el-dropdown-item>
|
||
<el-dropdown-item command="edit">编辑</el-dropdown-item>
|
||
<el-dropdown-item v-if="false" command="activate">开通</el-dropdown-item> <!-- 暂时隐藏开通功能 (勿删, 需要时去掉 v-if="false") -->
|
||
<el-dropdown-item command="delAnn">删除公告</el-dropdown-item>
|
||
<el-dropdown-item command="rate">执行单位评分</el-dropdown-item>
|
||
<el-dropdown-item v-if="canDelete" command="delete">删除项目</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</template>
|
||
</div>
|
||
</div></template>
|
||
</el-table-column>
|
||
</GrTable>
|
||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
|
||
</div>
|
||
|
||
<!-- 项目详情已迁到独立页面 /manager/projects/detail/:projectId -->
|
||
|
||
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
|
||
|
||
<!-- ============= 新建项目 dialog(已迁移到独立页面 /manager/projects/new) ============= -->
|
||
|
||
<!-- 项目分配 dialog 已迁移到独立子页面 /manager/projects/assign (单条) 和 /assign-batch (批量) -->
|
||
|
||
<!-- ============= 批量评分 dialog(按原型 4 维度评分) ============= -->
|
||
<el-dialog v-model="batchOpen" :title="batchMap[batchKind]" width="626px" :show-close="false">
|
||
<!-- 评分专用字段: 共享一份评分 (与 singleScoreForm 同样的三列 table 布局) -->
|
||
<template v-if="batchKind==='score'">
|
||
<p style="color:#606266;margin-bottom:8px">将对以下 <b>{{ selection.length }}</b> 个项目共用同一份评分:</p>
|
||
<table class="score-table" style="width:100%;border-collapse:collapse;font-size:13px">
|
||
<thead><tr style="background:#fafafa">
|
||
<th style="width:110px;padding:10px 12px;text-align:left">评价维度</th>
|
||
<th style="padding:10px 12px;text-align:left">评价内容(简洁版)</th>
|
||
<th style="width:130px;padding:10px 12px;text-align:center">评分标准(1~5 分)</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">履约质量</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">服务/活动效果达标度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.qualityScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">时效响应</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">执行 & 售后响应速度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.responseScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">配合度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">沟通配合 & 问题处理</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.cooperationScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">合规安全</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">流程合规 & 无安全事故</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.complianceScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||
</tbody>
|
||
</table>
|
||
</template>
|
||
<!-- 结题专用字段 -->
|
||
<template v-else-if="batchKind==='close'">
|
||
<p>已选择 <b>{{ selection.length }}</b> 个项目。</p>
|
||
<p style="color:#606266;margin-top:8px">确定要将这些项目标记为结题吗?</p>
|
||
</template>
|
||
<!-- 开通专用字段 -->
|
||
<template v-else-if="batchKind==='activate'">
|
||
<p>已选择 <b>{{ selection.length }}</b> 个项目。</p>
|
||
<el-form-item label="开通截止时间"><el-date-picker v-model="activateDeadline" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||
</template>
|
||
<template #footer>
|
||
<el-button @click="batchOpen=false">取消</el-button>
|
||
<el-button type="primary" :loading="batchSubmitting" @click="confirmBatch">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ============= 单行结题 modal ============= -->
|
||
<el-dialog v-model="closeModalOpen" title="结题确认" width="420px" :show-close="false">
|
||
<p style="color:#606266">你确定要结题吗?</p>
|
||
<template #footer>
|
||
<el-button type="primary" @click="confirmClose">确定</el-button>
|
||
<el-button @click="closeModalOpen=false">取消</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ============= 单行开通 modal (置 open_status=Y + 开通截止时间 open_deadline) ============= -->
|
||
<el-dialog v-model="activateModalOpen" title="开通项目" width="420px" :show-close="false">
|
||
<el-form-item label="开通截止时间">
|
||
<el-date-picker v-model="activateDeadlineSingle" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||
</el-form-item>
|
||
<template #footer>
|
||
<el-button type="primary" @click="confirmActivate">确定</el-button>
|
||
<el-button @click="activateModalOpen=false">取消</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ============= 单行删除公告 modal ============= -->
|
||
<el-dialog v-model="delAnnModalOpen" title="您确定要删除公告吗?删除后无法恢复" width="420px" :show-close="false">
|
||
<template #footer>
|
||
<el-button type="primary" @click="confirmDeleteAnn">确定</el-button>
|
||
<el-button @click="delAnnModalOpen=false">取消</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ============= 单行执行单位评分 modal(4 维度) ============= -->
|
||
<el-dialog v-model="singleScoreModalOpen" title="执行单位评分" width="626px" :show-close="false">
|
||
<table class="score-table" style="width:100%;border-collapse:collapse;font-size:13px">
|
||
<thead><tr style="background:#fafafa">
|
||
<th style="width:110px;padding:10px 12px;text-align:left">评价维度</th>
|
||
<th style="padding:10px 12px;text-align:left">评价内容(简洁版)</th>
|
||
<th style="width:130px;padding:10px 12px;text-align:center">评分标准(1~5 分)</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">履约质量</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">服务/活动效果达标度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.qualityScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">时效响应</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">执行 & 售后响应速度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.responseScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">配合度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">沟通配合 & 问题处理</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.cooperationScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">合规安全</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">流程合规 & 无安全事故</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.complianceScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||
</tbody>
|
||
</table>
|
||
<template #footer>
|
||
<el-button type="primary" @click="confirmSingleScore">确定</el-button>
|
||
<el-button @click="singleScoreModalOpen=false">取消</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ============= 评分详情 modal(只读 4 维度明细, 点列表列查看) ============= -->
|
||
<el-dialog v-model="scoreDetailOpen" :title="scoreDetailTitle" width="520px" :show-close="false">
|
||
<table class="score-table" style="width:100%;border-collapse:collapse;font-size:13px">
|
||
<thead><tr style="background:#fafafa">
|
||
<th style="width:110px;padding:10px 12px;text-align:left">评价维度</th>
|
||
<th style="padding:10px 12px;text-align:left">评价内容(简洁版)</th>
|
||
<th style="width:110px;padding:10px 12px;text-align:center">平均得分</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">履约质量</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">服务/活动效果达标度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center">{{ scoreDetail.qualityScore ?? '-' }}</td></tr>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">时效响应</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">执行 & 售后响应速度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center">{{ scoreDetail.responseScore ?? '-' }}</td></tr>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">配合度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">沟通配合 & 问题处理</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center">{{ scoreDetail.cooperationScore ?? '-' }}</td></tr>
|
||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">合规安全</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">流程合规 & 无安全事故</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center">{{ scoreDetail.complianceScore ?? '-' }}</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<div style="margin-top:12px;text-align:right;font-size:13px;color:#303133">
|
||
平均分: <b>{{ scoreDetailTotal }}</b>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="scoreDetailOpen=false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { bizList, bizAdd, bizUpdate, bizDelete, exportProjectList, exportProjectRating, exportSignupExpert } from '@/api/public'
|
||
import { listExecutorOrgs, listSponsorOrgs } from '@/api/system'
|
||
import request from '@/utils/request'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { ArrowDown } from '@element-plus/icons-vue'
|
||
import Preview from '@/components/Preview.vue'
|
||
import GrTable from '@/components/GrTable.vue'
|
||
import { useUserStore } from '@/store/user'
|
||
const userStore = useUserStore()
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
// 本列表页被 admin/manager/leader 三角色共用, 跳子页面须按当前角色前缀 (否则被 beforeEach 角色守卫拦回工作台)
|
||
const basePath = computed(() => userStore.role === 'admin' ? '/admin' : userStore.role === 'leader' ? '/leader' : '/manager')
|
||
|
||
const q = ref({ sponsorOrgIds: [], execOrgIds: [], projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '' })
|
||
const rows = ref([])
|
||
const loading = ref(false)
|
||
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const params = { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize }
|
||
// 时间范围含两端: 开始日 00:00:00 ~ 结束日 23:59:59
|
||
if (params.startTime) params.startTime = params.startTime + ' 00:00:00'
|
||
if (params.endTime) params.endTime = params.endTime + ' 23:59:59'
|
||
// axios 拦截器已将 TableDataInfo 归一化为 {code, msg, data: {total, rows}}
|
||
const { data } = await bizList('project', params)
|
||
rows.value = data?.rows || []
|
||
page.total = data?.total || 0
|
||
} catch (e) {
|
||
rows.value = []; page.total = 0
|
||
} finally { loading.value = false }
|
||
}
|
||
// 操作列按钮 handlers
|
||
function onAction(cmd, row) {
|
||
if (cmd === 'close') openClose(row)
|
||
else if (cmd === 'view') viewDetail(row)
|
||
else if (cmd === 'edit') doEdit(row)
|
||
else if (cmd === 'activate') openActivate(row)
|
||
else if (cmd === 'delAnn') openDeleteAnnouncement(row)
|
||
else if (cmd === 'rate') openSingleScore(row)
|
||
else if (cmd === 'delete') doDelete(row)
|
||
}
|
||
// 仅 admin 可见删除按钮 (前端 v-if, 后端 controller 再兜底 role 校验)
|
||
const canDelete = computed(() => {
|
||
const r = userStore.role
|
||
return r === 'admin'
|
||
})
|
||
function doCreateMeeting(row) { router.push(`${basePath.value}/meetings/new?projectId=${row.projectId}`) }
|
||
function isFinishedRow(row) { return row.isFinished === '1' || row.isFinished === 1 }
|
||
function doAssign(row) {
|
||
if (isFinishedRow(row)) return ElMessage.warning('已结题项目不能分配')
|
||
// 跳转独立子页面 /manager/projects/assign?projectId=X
|
||
router.push({ path: `${basePath.value}/projects/assign`, query: { projectId: row.projectId } })
|
||
}
|
||
function doEdit(row) { router.push(`${basePath.value}/projects/edit/${row.projectId}`) }
|
||
|
||
// 删除项目 (软删除 + 级联置 5 张项目表 + 项目下所有会议 is_deleted=1)
|
||
// 后端 BizProjectController.remove 已强制仅 admin, 前端只做可见性 v-if
|
||
async function doDelete(row) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确认删除项目"${row.projectName}"?`,
|
||
'删除确认',
|
||
{ type: 'error', confirmButtonText: '确认删除', cancelButtonText: '取消' }
|
||
)
|
||
await bizDelete('project', row.projectId)
|
||
ElMessage.success('已删除')
|
||
// 从 selection 里移除已删行, 避免批量按钮残留
|
||
selection.value = selection.value.filter(r => r.projectId !== row.projectId)
|
||
load()
|
||
} catch (e) {
|
||
if (e !== 'cancel' && e !== 'close') ElMessage.error(e?.msg || '删除失败')
|
||
}
|
||
}
|
||
|
||
// 批量软删除 (admin/manager)
|
||
async function onBatchDelete() {
|
||
if (!selection.value.length) return ElMessage.warning('请先勾选项目')
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确认删除选中的 ${selection.value.length} 个项目?`,
|
||
'批量删除确认',
|
||
{ type: 'error', confirmButtonText: `确认删除 ${selection.value.length} 个`, cancelButtonText: '取消' }
|
||
)
|
||
} catch { return }
|
||
const ids = selection.value.map(r => r.projectId)
|
||
try {
|
||
await bizDelete('project', ids)
|
||
ElMessage.success(`已删除 ${ids.length} 个项目`)
|
||
selection.value = []
|
||
load()
|
||
} catch (e) {
|
||
ElMessage.error(e?.msg || '批量删除失败')
|
||
}
|
||
}
|
||
|
||
function reset() { q.value = { sponsorOrgIds:[], execOrgIds:[], projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'' }; page.pageNum = 1; load() }
|
||
|
||
// 读 URL query 写入 q (Workbench KPI 卡跳转时带 ?isFinished=1 / ?isSettled=Y 等, 让列表页自动应用筛选)
|
||
// 注意: 只读不改 URL — 列表页内 reset()/search 不反向写 URL, Workbench 是 source of truth
|
||
function readQueryFromRoute() {
|
||
const q2 = route.query
|
||
if (q2.isFinished != null && q2.isFinished !== '') q.value.isFinished = String(q2.isFinished)
|
||
if (q2.isSettled != null && q2.isSettled !== '') q.value.isSettled = String(q2.isSettled)
|
||
}
|
||
|
||
// 「已报名专家导出」: manager + admin 可见 (前端 v-if, 后端 controller 再兜底 role 校验)
|
||
const isManager = computed(() => userStore.role === 'manager')
|
||
const isAdmin = computed(() => userStore.role === 'admin')
|
||
// 项目负责人(leader): 只读, 复用本列表页 (数据由后端 list 按 lead_user_id 隔离), 隐藏所有写操作
|
||
const isLeader = computed(() => userStore.role === 'leader')
|
||
|
||
// ========== 导出 (跟随当前筛选, 与 load() 同一口径) ==========
|
||
function buildExportParams() {
|
||
// 勾选了项目 → 只导出勾选项; 否则按当前筛选导出全部
|
||
if (selection.value.length > 0) {
|
||
return { projectIds: selection.value.map(r => r.projectId) }
|
||
}
|
||
const params = { ...q.value }
|
||
if (params.startTime) params.startTime = params.startTime + ' 00:00:00'
|
||
if (params.endTime) params.endTime = params.endTime + ' 23:59:59'
|
||
return params
|
||
}
|
||
function downloadBlob(res, filename) {
|
||
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = filename
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
function dateStamp() {
|
||
const d = new Date()
|
||
const p = (n) => String(n).padStart(2, '0')
|
||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
|
||
}
|
||
async function doExport() {
|
||
try {
|
||
const res = await exportProjectList(buildExportParams())
|
||
downloadBlob(res, `项目列表_${dateStamp()}.xlsx`)
|
||
ElMessage.success('导出成功')
|
||
} catch (e) {
|
||
ElMessage.error(e?.msg || '导出失败')
|
||
}
|
||
}
|
||
async function doExportRating() {
|
||
try {
|
||
const res = await exportProjectRating(buildExportParams())
|
||
downloadBlob(res, `项目评价_${dateStamp()}.xlsx`)
|
||
ElMessage.success('导出成功')
|
||
} catch (e) {
|
||
ElMessage.error(e?.msg || '导出失败')
|
||
}
|
||
}
|
||
async function doExportSignupExpert() {
|
||
try {
|
||
const res = await exportSignupExpert(buildExportParams())
|
||
downloadBlob(res, `报名专家_${dateStamp()}.xlsx`)
|
||
ElMessage.success('导出成功')
|
||
} catch (e) {
|
||
ElMessage.error(e?.msg || '导出失败')
|
||
}
|
||
}
|
||
|
||
// ========== 单行操作按钮 handlers(弹 5 个独立 dialog) ==========
|
||
function openClose(row) {
|
||
if (row.isFinished === '1' || row.isFinished === 1) {
|
||
return ElMessage.warning('该项目已结题,无需重复操作')
|
||
}
|
||
closeTargetRow.value = row
|
||
closeModalOpen.value = true
|
||
}
|
||
function confirmClose() {
|
||
if (!closeTargetRow.value) return
|
||
bizUpdate('project', { projectId: closeTargetRow.value.projectId, isFinished: '1' })
|
||
.then(() => { ElMessage.success('结题成功'); closeModalOpen.value = false; load() })
|
||
.catch(() => ElMessage.error('结题失败'))
|
||
}
|
||
|
||
function openActivate(row) {
|
||
activateTargetRow.value = row
|
||
activateDeadlineSingle.value = row.openDeadline || ''
|
||
activateModalOpen.value = true
|
||
}
|
||
function confirmActivate() {
|
||
if (!activateTargetRow.value) return
|
||
if (!activateDeadlineSingle.value) return ElMessage.warning('请选择开通截止时间')
|
||
bizUpdate('project', { projectId: activateTargetRow.value.projectId, openStatus: 'Y', openDeadline: activateDeadlineSingle.value })
|
||
.then(() => { ElMessage.success('已开通'); activateModalOpen.value = false; load() })
|
||
.catch(() => ElMessage.error('开通失败'))
|
||
}
|
||
|
||
function openDeleteAnnouncement(row) {
|
||
delAnnTargetRow.value = row
|
||
delAnnModalOpen.value = true
|
||
}
|
||
function confirmDeleteAnn() {
|
||
if (!delAnnTargetRow.value) return
|
||
// 删除公告: 后端专用端点, 将 3 个公示 URL 置 NULL (未发布由 URL 为空判定, 无独立 is_published 列)
|
||
request.delete(`/business/project/${delAnnTargetRow.value.projectId}/announcement`)
|
||
.then(() => { ElMessage.success(`已删除项目 ${delAnnTargetRow.value.projectNo} 的公告`); delAnnModalOpen.value = false; load() })
|
||
.catch(() => ElMessage.error('删除失败'))
|
||
}
|
||
|
||
async function openSingleScore(row) {
|
||
singleScoreTargetRow.value = row
|
||
singleScoreForm.qualityScore = 0
|
||
singleScoreForm.responseScore = 0
|
||
singleScoreForm.cooperationScore = 0
|
||
singleScoreForm.complianceScore = 0
|
||
singleScoreModalOpen.value = true
|
||
// 反显当前登录管理员的历史明细
|
||
const uid = userStore.user?.userId
|
||
if (!uid) return
|
||
try {
|
||
const r = await request({ url: '/business/project/ratings', method: 'get', params: { projectId: row.projectId } })
|
||
const list = (r.data && (Array.isArray(r.data) ? r.data : r.data.rows)) || r.rows || []
|
||
const mine = list.find(x =>
|
||
String(x.projectId) === String(row.projectId) &&
|
||
String(x.raterId) === String(uid) &&
|
||
x.raterRole === 'manager'
|
||
)
|
||
if (mine) {
|
||
singleScoreForm.qualityScore = Number(mine.qualityScore) || 0
|
||
singleScoreForm.responseScore = Number(mine.responseScore) || 0
|
||
singleScoreForm.cooperationScore = Number(mine.cooperationScore) || 0
|
||
singleScoreForm.complianceScore = Number(mine.complianceScore) || 0
|
||
}
|
||
} catch (e) { /* 拉取失败不影响打开, 维持默认 0 */ }
|
||
}
|
||
async function confirmSingleScore() {
|
||
if (!singleScoreTargetRow.value) return
|
||
const f = singleScoreForm
|
||
if (!f.qualityScore || f.qualityScore < 1 || f.qualityScore > 5) return ElMessage.error('履约质量必须是 1~5 的数字')
|
||
if (!f.responseScore || f.responseScore < 1 || f.responseScore > 5) return ElMessage.error('时效响应必须是 1~5 的数字')
|
||
if (!f.cooperationScore || f.cooperationScore < 1 || f.cooperationScore > 5) return ElMessage.error('配合度必须是 1~5 的数字')
|
||
if (!f.complianceScore || f.complianceScore < 1 || f.complianceScore > 5) return ElMessage.error('合规安全必须是 1~5 的数字')
|
||
const avg = ((f.qualityScore + f.responseScore + f.cooperationScore + f.complianceScore) / 4).toFixed(1)
|
||
const projectId = singleScoreTargetRow.value.projectId
|
||
try {
|
||
// 写 4 维度明细到 biz_project_rating (rater_role='manager', 后端自动填 raterId + 重算聚合分)
|
||
await request.post('/business/project/rate', {
|
||
projectId,
|
||
raterRole: 'manager',
|
||
qualityScore: f.qualityScore,
|
||
responseScore: f.responseScore,
|
||
cooperationScore: f.cooperationScore,
|
||
complianceScore: f.complianceScore
|
||
})
|
||
ElMessage.success(`评分完成,平均分:${avg}`)
|
||
singleScoreModalOpen.value = false
|
||
load()
|
||
} catch (e) {
|
||
ElMessage.error(e?.msg || '评分失败')
|
||
}
|
||
}
|
||
function viewDetail(row) {
|
||
// admin/leader 复用本列表页, 详情须跳到各自前缀 (否则被 beforeEach 角色守卫拦截)
|
||
router.push(`${basePath.value}/projects/detail/${row.projectId}`)
|
||
}
|
||
|
||
// 点击「总场次/已执行/未执行」列 → 跳对应角色的会议列表, 按项目 + 阶段筛选 (会议列表后端按 role 隔离数据权限)
|
||
// scope: 'all'(全部) | 'done'(已执行) | 'todo'(未执行)
|
||
function goMeetings(row, scope) {
|
||
const q = { projectId: row.projectId }
|
||
if (scope === 'done') q.currentStageNotIn = 'NOT_STARTED,IN_PROGRESS'
|
||
if (scope === 'todo') q.currentStage = 'NOT_STARTED'
|
||
router.push({ path: `${basePath.value}/meetings`, query: q })
|
||
}
|
||
|
||
// 点击列表「执行单位评分(合规/支持)」列 → 只读显示该角色 4 维度评分明细
|
||
async function openScoreDetail(row, role) {
|
||
scoreDetailTitle.value = role === 'sponsor' ? '执行单位评分详情(支持方)' : '执行单位评分详情(合规)'
|
||
Object.assign(scoreDetail, { qualityScore: null, responseScore: null, cooperationScore: null, complianceScore: null })
|
||
scoreDetailTotal.value = role === 'sponsor' ? (row.sponsorScore ?? '—') : (row.managerScore ?? '—')
|
||
scoreDetailOpen.value = true
|
||
try {
|
||
const r = await request({ url: '/business/project/ratings', method: 'get', params: { projectId: row.projectId } })
|
||
const list = (r.data && (Array.isArray(r.data) ? r.data : r.data.rows)) || r.rows || []
|
||
const items = list.filter(x => String(x.projectId) === String(row.projectId) && x.raterRole === role)
|
||
if (items.length) {
|
||
const n = items.length
|
||
const dimAvg = (key) => (items.reduce((s, x) => s + (Number(x[key]) || 0), 0) / n).toFixed(1)
|
||
scoreDetail.qualityScore = dimAvg('qualityScore')
|
||
scoreDetail.responseScore = dimAvg('responseScore')
|
||
scoreDetail.cooperationScore = dimAvg('cooperationScore')
|
||
scoreDetail.complianceScore = dimAvg('complianceScore')
|
||
}
|
||
} catch (e) { /* 拉取失败保持空, 弹窗显示 - */ }
|
||
}
|
||
|
||
// 金额格式化: 1234567.5 -> "1,234,567.50"
|
||
function fmtMoney(row, col, v) {
|
||
if (v === null || v === undefined || v === '') return ''
|
||
const n = Number(v)
|
||
if (isNaN(n)) return String(v)
|
||
return n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||
}
|
||
// 日期时间格式化: 展示完整 yyyy-MM-dd HH:mm:ss (后端 @JsonFormat 已序列化, 直接透传)
|
||
function fmtDateTime(row, col, v) {
|
||
if (!v) return ''
|
||
return String(v)
|
||
}
|
||
|
||
const selection = ref([])
|
||
function onSel(rs) { selection.value = rs }
|
||
const batchOpen = ref(false)
|
||
const batchKind = ref('')
|
||
const batchSubmitting = ref(false)
|
||
const batchMap = { score: '批量评分', close: '批量结题', activate: '批量开通' }
|
||
const batchScoreForm = reactive({ qualityScore: 5, responseScore: 5, cooperationScore: 5, complianceScore: 5 }) // 批量评分: 共享一份 4 维度
|
||
const activateDeadline = ref('')
|
||
const activateDeadlineSingle = ref('')
|
||
|
||
// 5 个单行 modal 状态
|
||
const closeModalOpen = ref(false)
|
||
const closeTargetRow = ref(null)
|
||
const activateModalOpen = ref(false)
|
||
const activateTargetRow = ref(null)
|
||
const delAnnModalOpen = ref(false)
|
||
const delAnnTargetRow = ref(null)
|
||
const singleScoreModalOpen = ref(false)
|
||
const singleScoreTargetRow = ref(null)
|
||
const singleScoreForm = reactive({ qualityScore: 0, responseScore: 0, cooperationScore: 0, complianceScore: 0 })
|
||
|
||
// ========== 评分详情 (只读 4 维度明细, 点列表 score 列查看) ==========
|
||
const scoreDetailOpen = ref(false)
|
||
const scoreDetailTitle = ref('评分详情')
|
||
const scoreDetail = reactive({ qualityScore: null, responseScore: null, cooperationScore: null, complianceScore: null })
|
||
const scoreDetailTotal = ref('—')
|
||
|
||
// 新建项目已迁到独立页面 /manager/projects/new (ManagerProjectsNew.vue)
|
||
function openNewProject() {
|
||
router.push(`${basePath.value}/projects/new`)
|
||
}
|
||
function openAssign() {
|
||
if (!selection.value.length) {
|
||
ElMessage.warning('请先勾选项目')
|
||
return
|
||
}
|
||
// 已结题项目不能分配: 过滤掉已结题项, 并提示跳过数
|
||
const targets = selection.value.filter(r => !isFinishedRow(r))
|
||
if (!targets.length) return ElMessage.warning('所选项目均已结题, 不能分配')
|
||
const skipCount = selection.value.length - targets.length
|
||
// 跳转独立子页面 /manager/projects/assign, 单条 vs 批量通过 query 区分:
|
||
// 单条: ?projectId=X → 子页面 v-if 单条分支
|
||
// 批量: ?projectIds=1,2,3 → 子页面 v-else 批量分支
|
||
if (targets.length === 1) {
|
||
router.push({ path: `${basePath.value}/projects/assign`, query: { projectId: targets[0].projectId } })
|
||
} else {
|
||
const ids = targets.map(r => r.projectId).join(',')
|
||
router.push({ path: `${basePath.value}/projects/assign`, query: { projectIds: ids } })
|
||
}
|
||
if (skipCount > 0) ElMessage.warning(`已跳过 ${skipCount} 个已结题项目`)
|
||
}
|
||
function openBatch(kind) {
|
||
if (!selection.value.length) return ElMessage.warning('请先勾选项目')
|
||
batchKind.value = kind
|
||
if (kind === 'score') {
|
||
// 共享评分: 重置 form 为默认值 (5 分)
|
||
Object.assign(batchScoreForm, { qualityScore: 5, responseScore: 5, cooperationScore: 5, complianceScore: 5 })
|
||
}
|
||
if (kind === 'activate') {
|
||
activateDeadline.value = ''
|
||
}
|
||
batchOpen.value = true
|
||
}
|
||
|
||
async function confirmBatch() {
|
||
if (batchKind.value === 'close') {
|
||
// 过滤掉已结题项目, 避免无效请求
|
||
const targets = selection.value.filter(r => r.isFinished !== '1' && r.isFinished !== 1)
|
||
const skipCount = selection.value.length - targets.length
|
||
try {
|
||
batchSubmitting.value = true
|
||
await Promise.all(targets.map(r => bizUpdate('project', { projectId: r.projectId, isFinished: '1' })))
|
||
ElMessage.success(skipCount > 0
|
||
? `已批量结题 ${targets.length} 个项目 (跳过 ${skipCount} 个已结题)`
|
||
: `已批量结题 ${targets.length} 个项目`)
|
||
batchOpen.value = false
|
||
selection.value = []
|
||
load()
|
||
} catch { ElMessage.error('批量结题失败') }
|
||
finally { batchSubmitting.value = false }
|
||
} else if (batchKind.value === 'activate') {
|
||
if (!activateDeadline.value) return ElMessage.warning('请选择开通截止时间')
|
||
try {
|
||
batchSubmitting.value = true
|
||
await Promise.all(selection.value.map(r => bizUpdate('project', { projectId: r.projectId, openStatus: 'Y', openDeadline: activateDeadline.value })))
|
||
ElMessage.success(`已批量开通 ${selection.value.length} 个项目`)
|
||
batchOpen.value = false
|
||
selection.value = []
|
||
load()
|
||
} catch { ElMessage.error('批量开通失败') }
|
||
finally { batchSubmitting.value = false }
|
||
} else if (batchKind.value === 'score') {
|
||
const f = batchScoreForm
|
||
if (!f.qualityScore || f.qualityScore < 1 || f.qualityScore > 5) return ElMessage.error('履约质量必须是 1~5 的数字')
|
||
if (!f.responseScore || f.responseScore < 1 || f.responseScore > 5) return ElMessage.error('时效响应必须是 1~5 的数字')
|
||
if (!f.cooperationScore || f.cooperationScore < 1 || f.cooperationScore > 5) return ElMessage.error('配合度必须是 1~5 的数字')
|
||
if (!f.complianceScore || f.complianceScore < 1 || f.complianceScore > 5) return ElMessage.error('合规安全必须是 1~5 的数字')
|
||
const total = ((f.qualityScore + f.responseScore + f.cooperationScore + f.complianceScore) / 4).toFixed(1)
|
||
try {
|
||
batchSubmitting.value = true
|
||
let ok = 0
|
||
// 共享评分: 所有项目用同一组 4 维度
|
||
for (const r of selection.value) {
|
||
try {
|
||
await request.post('/business/project/rate', {
|
||
projectId: r.projectId,
|
||
raterRole: 'manager',
|
||
qualityScore: f.qualityScore,
|
||
responseScore: f.responseScore,
|
||
cooperationScore: f.cooperationScore,
|
||
complianceScore: f.complianceScore
|
||
})
|
||
ok++
|
||
} catch {}
|
||
}
|
||
ElMessage.success(`已批量评分 ${ok}/${selection.value.length} 个项目 (共用评分 ${total})`)
|
||
batchOpen.value = false
|
||
selection.value = []
|
||
load()
|
||
} finally { batchSubmitting.value = false }
|
||
}
|
||
}
|
||
|
||
// ========== 新建项目 ==========
|
||
const newOpen = ref(false)
|
||
// 新建项目独立页面: /manager/projects/new (ManagerProjectsNew.vue)
|
||
|
||
// ========== 项目分配 dialog 已迁到独立子页面 /manager/projects/assign (单条) 和 /assign-batch (批量) ==========
|
||
// 仅保留顶部筛选区使用的远程搜索状态 (sponsorOrgOptions / executorOrgOptions), 不再保留 dialog 相关 state 和 functions
|
||
|
||
const sponsorOrgOptions = ref([])
|
||
const sponsorSearching = ref(false)
|
||
let _supporterFilterTimer = null
|
||
// 顶部筛选区专用
|
||
const executorOrgOptions = ref([])
|
||
const execSearching = ref(false)
|
||
let _executorFilterTimer = null
|
||
|
||
// 顶部筛选区远程搜索 (多选 el-select, 复用 sponsorOrgOptions, 加 loading + 已选项重拉回)
|
||
function searchSponsorOrgsForFilter(query) {
|
||
if (_supporterFilterTimer) clearTimeout(_supporterFilterTimer)
|
||
sponsorSearching.value = true
|
||
_supporterFilterTimer = setTimeout(async () => {
|
||
try {
|
||
const params = {}
|
||
if (query) params.orgName = query
|
||
const r = await listSponsorOrgs(params)
|
||
sponsorOrgOptions.value = r.data || []
|
||
// 已选项不在结果里时按 orgId 单独拉回 (multi-select 必须保证每个已选 tag 都有 label)
|
||
const ids = q.value.sponsorOrgIds || []
|
||
for (const id of ids) {
|
||
if (!sponsorOrgOptions.value.find(u => u.orgId === id)) {
|
||
const r2 = await listSponsorOrgs({ orgId: id })
|
||
const sel = (r2.data || []).find(u => u.orgId === id)
|
||
if (sel) sponsorOrgOptions.value = [sel, ...sponsorOrgOptions.value]
|
||
}
|
||
}
|
||
} catch (e) {
|
||
sponsorOrgOptions.value = []
|
||
} finally {
|
||
sponsorSearching.value = false
|
||
}
|
||
}, 200)
|
||
}
|
||
|
||
// 顶部筛选区远程搜索 (服务机构多选 el-select, 加 loading + 已选项重拉回)
|
||
function searchExecutorOrgsForFilter(query) {
|
||
if (_executorFilterTimer) clearTimeout(_executorFilterTimer)
|
||
execSearching.value = true
|
||
_executorFilterTimer = setTimeout(async () => {
|
||
try {
|
||
const params = {}
|
||
if (query) params.orgName = query
|
||
const r = await listExecutorOrgs(params)
|
||
executorOrgOptions.value = r.data || []
|
||
// 已选项不在结果里时按 orgId 单独拉回 (multi-select 必须保证每个已选 tag 都有 label)
|
||
const ids = q.value.execOrgIds || []
|
||
for (const id of ids) {
|
||
if (!executorOrgOptions.value.find(u => u.orgId === id)) {
|
||
const r2 = await listExecutorOrgs({ orgId: id })
|
||
const sel = (r2.data || []).find(u => u.orgId === id)
|
||
if (sel) executorOrgOptions.value = [sel, ...executorOrgOptions.value]
|
||
}
|
||
}
|
||
} catch (e) {
|
||
executorOrgOptions.value = []
|
||
} finally {
|
||
execSearching.value = false
|
||
}
|
||
}, 200)
|
||
}
|
||
|
||
const previewOpen = ref(false)
|
||
const previewUrl = ref('')
|
||
const previewTitle = ref('附件预览')
|
||
function openPreview(url, title) { previewUrl.value = url; previewTitle.value = title || '附件预览'; previewOpen.value = true }
|
||
|
||
onMounted(() => { readQueryFromRoute(); load() })
|
||
// 监听路由变化 (从 /manager/projects/new 跳回时强制重载, query 变了也重新应用筛选)
|
||
watch(() => route.fullPath, (newPath, oldPath) => {
|
||
if (newPath === '/manager/projects') {
|
||
readQueryFromRoute()
|
||
load()
|
||
}
|
||
})
|
||
// 兜底: 即便路由完全相同,从列表跳到详情再回来也触发
|
||
watch(() => route.path + (route.query._t || ''), () => load())
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 1:1 抄 sponsor/SponsorProjects.vue */
|
||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||
|
||
/* 操作列下拉 */
|
||
.op-cell { display: inline-flex; align-items: center; gap: 12px; }
|
||
.op-cell :deep(.el-button) { margin-left: 0 !important; padding: 0 4px !important; }
|
||
.op-dropdown { padding-right: 0; }
|
||
.op-caret { margin-left: 2px; font-size: 10px; }
|
||
|
||
/* 工具按钮 (与 sponsor 一致) */
|
||
.toolbar :deep(.action-btn) {
|
||
height: 32px; padding: 0 16px; font-size: 13px; border-radius: 4px;
|
||
background: var(--brand-primary); color: #fff; border: 1px solid var(--brand-primary);
|
||
display: inline-flex; align-items: center; justify-content: center; white-space: nowrap;
|
||
transition: opacity .15s;
|
||
}
|
||
.toolbar :deep(.action-btn:hover) { opacity: .85; }
|
||
.toolbar :deep(.action-btn.secondary) {
|
||
background: #fff; color: var(--brand-primary); border: 1px solid var(--brand-primary);
|
||
}
|
||
.toolbar :deep(.action-btn:disabled) {
|
||
background: #f5f7fa; color: #c0c4cc; border-color: #e4e7ed; cursor: not-allowed;
|
||
}
|
||
.toolbar :deep(.badge) {
|
||
margin-left: 4px; padding: 0 6px; background: var(--brand-primary); color: #fff;
|
||
border-radius: 8px; font-size: 11px; line-height: 16px;
|
||
}
|
||
.toolbar :deep(.action-btn.secondary .badge) { background: var(--brand-primary); color: #fff; }
|
||
.page-toolbar :deep(.action-btn.secondary:hover) { background: rgba(30,58,138,.06); }
|
||
.page-toolbar :deep(.action-btn.is-disabled),
|
||
.page-toolbar :deep(.action-btn:disabled) { background: #f5f7fa; color: #c0c4cc; border-color: #e4e7ed; opacity: 1; cursor: not-allowed; }
|
||
.page-toolbar :deep(.action-btn.secondary.is-disabled),
|
||
.page-toolbar :deep(.action-btn.secondary:disabled) { background: #fff; color: #c0c4cc; border-color: #e4e7ed; }
|
||
.page-toolbar :deep(.action-btn .badge) {
|
||
display: inline-block; margin-left: 6px; padding: 0 6px; min-width: 18px; height: 18px;
|
||
line-height: 18px; font-size: 11px; text-align: center;
|
||
background: #fff; color: var(--brand-primary); border-radius: 9px;
|
||
}
|
||
.page-toolbar :deep(.action-btn.secondary .badge) { background: var(--brand-primary); color: #fff; }
|
||
.score-row { padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
|
||
.score-row:last-child { border-bottom: none; }
|
||
.score-title { font-weight: 600; color: #303133; margin-bottom: 6px; }
|
||
.score-no { color: #909399; font-weight: normal; font-size: 12px; }
|
||
.score-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.score-item { display: flex; align-items: center; gap: 8px; }
|
||
.score-item span { width: 70px; color: #606266; font-size: 13px; }
|
||
|
||
/* 分配 dialog 总场次条 */
|
||
.assign-sessions-bar {
|
||
display: flex; align-items: center; gap: 18px;
|
||
margin: -4px 0 12px; padding: 10px 14px;
|
||
background: #f5f7fa; border: 1px solid #ebeef5; border-radius: 4px;
|
||
font-size: 13px; color: #606266;
|
||
}
|
||
.assign-sessions-bar b { color: #303133; font-size: 14px; margin: 0 2px; }
|
||
.assign-sessions-bar .is-over b { color: #f56c6c; }
|
||
.assign-sessions-bar .over-warn { color: #f56c6c; font-weight: 600; }
|
||
.assign-sessions-bar .hint { color: #909399; font-size: 12px; }
|
||
.assign-sessions-bar .sep { color: #dcdfe6; margin: 0 4px; }
|
||
|
||
/* ========================================
|
||
移动端适配 (≤768px)
|
||
- 卡片 padding 收窄
|
||
- 工具栏横向滚动 (6 个按钮铺不下, 用 overflow-x:auto 让用户滑动)
|
||
- filter-form 全宽垂直堆叠
|
||
- 表格字号缩小 (横向滚动靠 el-table 自带)
|
||
======================================== */
|
||
@media (max-width: 768px) {
|
||
/* 卡片 padding */
|
||
.page-card { padding: 12px !important; border-radius: 4px !important; }
|
||
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
|
||
|
||
/* 工具栏: 横向滚动, 不换行 (6 个按钮) */
|
||
.toolbar {
|
||
flex-wrap: nowrap !important;
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
padding-bottom: 6px;
|
||
margin-bottom: 8px !important;
|
||
scrollbar-width: thin;
|
||
}
|
||
.toolbar::-webkit-scrollbar { height: 4px; }
|
||
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
|
||
.toolbar :deep(.action-btn) {
|
||
flex-shrink: 0;
|
||
font-size: 12px !important;
|
||
padding: 0 10px !important;
|
||
height: 30px !important;
|
||
}
|
||
|
||
/* filter-form: label 与输入框保持横向 (label 左, input 右, 跟桌面一致) */
|
||
.filter-form {
|
||
display: flex !important;
|
||
flex-direction: column !important;
|
||
align-items: stretch !important;
|
||
gap: 10px !important;
|
||
}
|
||
.filter-form :deep(.el-form-item) {
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
margin-right: 0 !important;
|
||
margin-bottom: 0 !important;
|
||
}
|
||
.filter-form :deep(.el-form-item__label) {
|
||
float: none !important;
|
||
width: auto !important;
|
||
min-width: 80px !important;
|
||
text-align: right !important;
|
||
padding: 0 8px 0 0 !important;
|
||
font-size: 13px !important;
|
||
color: var(--el-text-color-regular) !important;
|
||
line-height: 32px !important;
|
||
height: 32px !important;
|
||
}
|
||
.filter-form :deep(.el-form-item__content) {
|
||
margin-left: 0 !important;
|
||
line-height: 32px !important;
|
||
flex: 1 !important;
|
||
min-width: 0 !important;
|
||
}
|
||
/* 强制所有控件全宽, 覆盖 inline 260px / 140px / 120px */
|
||
.filter-form :deep(.el-select),
|
||
.filter-form :deep(.el-input),
|
||
.filter-form :deep(.el-date-editor),
|
||
.filter-form :deep(.el-button) {
|
||
width: 100% !important;
|
||
min-width: 0 !important;
|
||
}
|
||
/* 项目时间区间: 横向保持 (日期 → 至 → 日期) */
|
||
.filter-form :deep(.el-date-editor) { max-width: none !important; }
|
||
|
||
/* 表格字号缩小, 横向滚动靠 el-table 自带 */
|
||
:deep(.el-table) { font-size: 12px !important; }
|
||
/* 操作列固定到右侧, 加上阴影区隔 */
|
||
:deep(.el-table .fixed-right) {
|
||
box-shadow: -2px 0 6px rgba(0, 0, 0, 0.04);
|
||
}
|
||
}
|
||
</style> |