Files
guoju0808/ry-vue3/src/views/manager/Projects.vue
T
郭庆泰 56615f9806 feat: OA(ecology)项目拉取 + 会议邀请函PDF/微信分享 + 参会人/执行意向增强
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 + 供应商/招标接口切生产域名
2026-09-03 20:12:15 +08:00

951 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
<!-- ============= 单行执行单位评分 modal4 维度 ============= -->
<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>