feat(pl): add plan contract completion page with inline expand

List only plans missing contracts and allow per-side multi-select save in an expand row, reusing PlPlanContractMultiSelect without contract CRUD.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-20 12:16:38 +08:00
1 parent 62bdf48b63
commit 368cb5567a
1 file changed
+703
@@ -0,0 +1,703 @@
<!-- 运输计划合同完善:仅缺合同计划,行内展开分侧保存 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top pl-plan-contract-panel-top">
<h2 class="title-bar-heading">运输计划合同完善</h2>
<span class="pl-plan-contract-stat">待完善 {{ pagination.total }} 条</span>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="计划编号">
<el-input
v-model="searchForm.planNo"
placeholder="搜索计划编号"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="缺口类型">
<el-select v-model="searchForm.gapType" placeholder="全部缺口" clearable>
<el-option label="全部缺口" value="" />
<el-option label="仅缺供方合同" value="supplier" />
<el-option label="仅缺需方合同" value="demander" />
<el-option label="两侧均缺" value="both" />
</el-select>
</el-form-item>
<el-form-item label="供方 / 需方">
<el-input
v-model="searchForm.customerKeyword"
placeholder="客户名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="计划日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row pl-plan-list-toolbar-row">
<ListToolbarDateShortcuts
variant="pills"
:active="activeDateShortcut"
aria-label="计划日期快捷筛选"
@change="handleDateShortcut"
/>
<ListQuickSearchInput
v-model="quickSearch"
placeholder="计划编号/供方/需方"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-ghost" :loading="loading" @click="fetchData">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
ref="tableRef"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
:expand-row-keys="expandedRowKeys"
@expand-change="handleExpandChange"
@row-dblclick="handleView"
>
<el-table-column type="expand" min-width="48">
<template #default="{ row }">
<div class="pl-contract-expand" v-if="expandDraft[row.id]">
<div class="pl-contract-expand__grid">
<div
class="pl-contract-side"
:class="sideBoxClass(row, 'supplier')"
>
<div class="pl-contract-side__head">
<h4>供方合同</h4>
<el-tag
:type="sideTagType(row, 'supplier')"
size="small"
:effect="row.supplierContractExempt ? 'plain' : 'dark'"
>
{{ sideTagLabel(row, 'supplier') }}
</el-tag>
</div>
<p class="pl-contract-side__customer">
{{ row.supplierName || '—' }}
</p>
<template v-if="row.supplierContractExempt">
<p class="pl-contract-side__exempt">内置公司,合同可免</p>
</template>
<template v-else>
<PlPlanContractMultiSelect
v-model="expandDraft[row.id].supplierContracts"
:customer-id="row.supplierId"
:disabled="!row.supplierId"
:required="false"
/>
<div class="pl-contract-side__actions">
<el-button
class="btn-main"
type="primary"
size="small"
:loading="savingKey === `${row.id}:supplier`"
@click="handleSaveSide(row, 'supplier')"
>
<el-icon><Check /></el-icon>保存供方合同
</el-button>
</div>
</template>
</div>
<div
class="pl-contract-side"
:class="sideBoxClass(row, 'demander')"
>
<div class="pl-contract-side__head">
<h4>需方合同</h4>
<el-tag
:type="sideTagType(row, 'demander')"
size="small"
:effect="row.demanderContractExempt ? 'plain' : 'dark'"
>
{{ sideTagLabel(row, 'demander') }}
</el-tag>
</div>
<p class="pl-contract-side__customer">
{{ row.customerName || '—' }}
</p>
<template v-if="row.demanderContractExempt">
<p class="pl-contract-side__exempt">内置公司,合同可免</p>
</template>
<template v-else>
<PlPlanContractMultiSelect
v-model="expandDraft[row.id].demanderContracts"
:customer-id="row.customerId"
:disabled="!row.customerId"
:required="false"
/>
<div class="pl-contract-side__actions">
<el-button
class="btn-main"
type="primary"
size="small"
:loading="savingKey === `${row.id}:demander`"
@click="handleSaveSide(row, 'demander')"
>
<el-icon><Check /></el-icon>保存需方合同
</el-button>
</div>
</template>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
:index="index => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="planNo" label="计划编号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="handleView(row)">{{ row.planNo }}</el-link>
</template>
</el-table-column>
<el-table-column prop="planDate" label="计划日期" min-width="120" align="center" />
<el-table-column
prop="supplierName"
label="供方"
min-width="200"
align="left"
show-overflow-tooltip
>
<template #default="{ row }">
<div class="party-cell">
<span>{{ row.supplierName || '—' }}</span>
<span class="party-cell__sub">{{ partySubText(row, 'supplier') }}</span>
</div>
</template>
</el-table-column>
<el-table-column
prop="customerName"
label="需方"
min-width="200"
align="left"
show-overflow-tooltip
>
<template #default="{ row }">
<div class="party-cell">
<span>{{ row.customerName || '—' }}</span>
<span class="party-cell__sub">{{ partySubText(row, 'demander') }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="合同缺口" min-width="130" align="center">
<template #default="{ row }">
<el-tag :type="gapTag(row).type" size="small" effect="dark">
{{ gapTag(row).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="followStatus" label="跟进状态" min-width="110" align="center">
<template #default="{ row }">
<el-tag
:type="getPlPlanFollowStatusTagType(row.followStatus)"
size="small"
effect="dark"
>
{{ getPlPlanFollowStatusLabel(row.followStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" min-width="200" align="center">
<template #default="{ row }">
<el-button
link
type="warning"
@click="toggleExpand(row)"
>
<el-icon><EditPen /></el-icon>
{{ isExpanded(row.id) ? '收起' : '展开补充' }}
</el-button>
<el-button link type="primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看计划
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</section>
<PlPlanViewDialog
ref="viewDialogRef"
v-model:visible="viewVisible"
:plan-id="viewPlanId"
/>
</div>
</template>
<script setup lang="ts">
import { Check, EditPen, Refresh, RefreshLeft, Search, View } from '@element-plus/icons-vue'
import {
planApi,
type PlanContractGapItem,
type PlanContractLink
} from '@/api/pl'
import PlPlanContractMultiSelect from '@/components/business/pl-plan-contract-multi-select/index.vue'
import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
import { useListDateShortcut } from '@/composables/useListDateShortcut'
import {
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType
} from '@/utils/pl-display'
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
import { useMenuStore } from '@/store/modules/menu'
const menuStore = useMenuStore()
const loading = ref(false)
const tableRef = ref()
const tableData = ref<PlanContractGapItem[]>([])
const dateRange = ref<string[]>([])
const quickSearch = ref('')
const expandedRowKeys = ref<string[]>([])
const expandDraft = reactive<
Record<
string,
{
supplierContracts: PlanContractLink[]
demanderContracts: PlanContractLink[]
baselineSupplier: string
baselineDemander: string
}
>
>({})
const savingKey = ref('')
const viewDialogRef = ref<InstanceType<typeof PlPlanViewDialog>>()
const viewVisible = ref(false)
const viewPlanId = ref('')
const searchForm = reactive({
planNo: '',
gapType: '' as '' | 'supplier' | 'demander' | 'both',
customerKeyword: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
function cloneLinks(links?: PlanContractLink[]): PlanContractLink[] {
return (links || []).map(item => ({
contractId: item.contractId,
contractNo: item.contractNo
}))
}
function linksFingerprint(links: PlanContractLink[]) {
return cloneLinks(links)
.map(item => item.contractId)
.sort()
.join(',')
}
function gapTag(row: PlanContractGapItem) {
const sides = row.gapSides || []
if (sides.includes('supplier') && sides.includes('demander')) {
return { text: '两侧均缺', type: 'danger' as const }
}
if (sides.includes('supplier')) {
return { text: '缺供方合同', type: 'warning' as const }
}
if (sides.includes('demander')) {
return { text: '缺需方合同', type: 'warning' as const }
}
return { text: '—', type: 'info' as const }
}
function partySubText(row: PlanContractGapItem, side: 'supplier' | 'demander') {
if (side === 'supplier') {
if (row.supplierContractExempt) return '内置 · 合同可免'
const n = row.supplierContracts?.length || 0
return n ? `已关联 ${n} 份` : '未关联'
}
if (row.demanderContractExempt) return '内置 · 合同可免'
const n = row.demanderContracts?.length || 0
return n ? `已关联 ${n} 份` : '未关联'
}
function sideBoxClass(row: PlanContractGapItem, side: 'supplier' | 'demander') {
const exempt = side === 'supplier' ? row.supplierContractExempt : row.demanderContractExempt
if (exempt) return 'is-exempt'
const missing = (row.gapSides || []).includes(side)
return missing ? 'is-need' : 'is-ok'
}
function sideTagLabel(row: PlanContractGapItem, side: 'supplier' | 'demander') {
const exempt = side === 'supplier' ? row.supplierContractExempt : row.demanderContractExempt
if (exempt) return '可免'
return (row.gapSides || []).includes(side) ? '待补充' : '已齐'
}
function sideTagType(row: PlanContractGapItem, side: 'supplier' | 'demander') {
const exempt = side === 'supplier' ? row.supplierContractExempt : row.demanderContractExempt
if (exempt) return 'info'
return (row.gapSides || []).includes(side) ? 'warning' : 'success'
}
function isExpanded(id: string) {
return expandedRowKeys.value.includes(id)
}
function ensureDraft(row: PlanContractGapItem) {
if (expandDraft[row.id]) return
const supplierContracts = cloneLinks(row.supplierContracts)
const demanderContracts = cloneLinks(row.demanderContracts)
expandDraft[row.id] = {
supplierContracts,
demanderContracts,
baselineSupplier: linksFingerprint(supplierContracts),
baselineDemander: linksFingerprint(demanderContracts)
}
}
function isDraftDirty(id: string) {
const draft = expandDraft[id]
if (!draft) return false
return (
linksFingerprint(draft.supplierContracts) !== draft.baselineSupplier ||
linksFingerprint(draft.demanderContracts) !== draft.baselineDemander
)
}
async function collapseRow(id: string) {
if (isDraftDirty(id)) {
try {
await ElMessageBox.confirm('当前有未保存的合同变更,确认收起吗?', '提示', {
type: 'warning',
confirmButtonText: '收起',
cancelButtonText: '继续编辑'
})
} catch {
return false
}
}
delete expandDraft[id]
expandedRowKeys.value = expandedRowKeys.value.filter(key => key !== id)
return true
}
async function expandRow(row: PlanContractGapItem) {
const current = expandedRowKeys.value[0]
if (current && current !== row.id) {
const ok = await collapseRow(current)
if (!ok) return
}
ensureDraft(row)
expandedRowKeys.value = [row.id]
}
async function toggleExpand(row: PlanContractGapItem) {
if (isExpanded(row.id)) {
await collapseRow(row.id)
} else {
await expandRow(row)
}
}
async function handleExpandChange(row: PlanContractGapItem, expandedRows: PlanContractGapItem[]) {
const willExpand = expandedRows.some(item => item.id === row.id)
if (willExpand) {
const current = expandedRowKeys.value[0]
if (current && current !== row.id) {
const ok = await collapseRow(current)
if (!ok) {
nextTick(() => {
tableRef.value?.toggleRowExpansion?.(row, false)
})
return
}
}
ensureDraft(row)
expandedRowKeys.value = [row.id]
} else if (expandedRowKeys.value.includes(row.id)) {
const ok = await collapseRow(row.id)
if (!ok) {
nextTick(() => {
tableRef.value?.toggleRowExpansion?.(row, true)
})
}
}
}
async function fetchData() {
loading.value = true
try {
const keyword = quickSearch.value.trim()
const res = await planApi.listContractGaps({
q: keyword || undefined,
planNo: keyword ? undefined : searchForm.planNo || undefined,
gapType: searchForm.gapType || undefined,
customerKeyword: keyword ? undefined : searchForm.customerKeyword || undefined,
planDateFrom: dateRange.value?.[0] || undefined,
planDateTo: dateRange.value?.[1] || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
const data = (res as any).data ?? res
tableData.value = data.list || []
pagination.total = data.total || 0
// 收起已不在列表中的展开行
const ids = new Set(tableData.value.map(item => item.id))
expandedRowKeys.value = expandedRowKeys.value.filter(id => ids.has(id))
Object.keys(expandDraft).forEach(id => {
if (!ids.has(id)) delete expandDraft[id]
})
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
searchForm.planNo = ''
searchForm.gapType = ''
searchForm.customerKeyword = ''
quickSearch.value = ''
resetDateShortcut()
pagination.page = 1
fetchData()
}
function handleQuickSearch() {
searchForm.planNo = ''
searchForm.customerKeyword = ''
pagination.page = 1
fetchData()
}
function handleQuickSearchClear() {
quickSearch.value = ''
handleQuickSearch()
}
const { activeDateShortcut, handleDateShortcut, resetDateShortcut } = useListDateShortcut(
dateRange,
handleSearch
)
function handlePageChange() {
fetchData()
}
function handleSizeChange() {
pagination.page = 1
fetchData()
}
function handleView(row: PlanContractGapItem) {
viewPlanId.value = row.id
viewVisible.value = true
}
async function handleSaveSide(row: PlanContractGapItem, side: 'supplier' | 'demander') {
const draft = expandDraft[row.id]
if (!draft) return
const contracts =
side === 'supplier' ? draft.supplierContracts : draft.demanderContracts
savingKey.value = `${row.id}:${side}`
try {
const res = await planApi.updateSideContracts(row.id, side, contracts)
const updated = ((res as any).data ?? res) as PlanContractGapItem
ElMessage.success(side === 'supplier' ? '供方合同已保存' : '需方合同已保存')
if (!(updated.gapSides || []).length) {
tableData.value = tableData.value.filter(item => item.id !== row.id)
pagination.total = Math.max(0, pagination.total - 1)
delete expandDraft[row.id]
expandedRowKeys.value = []
await refreshMenuBadges(menuStore.menuList)
return
}
const idx = tableData.value.findIndex(item => item.id === row.id)
if (idx >= 0) {
tableData.value[idx] = updated
}
draft.supplierContracts = cloneLinks(updated.supplierContracts)
draft.demanderContracts = cloneLinks(updated.demanderContracts)
draft.baselineSupplier = linksFingerprint(draft.supplierContracts)
draft.baselineDemander = linksFingerprint(draft.demanderContracts)
await refreshMenuBadges(menuStore.menuList)
} finally {
savingKey.value = ''
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="scss">
.pl-plan-contract-panel-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px 12px;
border-bottom: 1px solid #eef2f6;
}
.pl-plan-contract-stat {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: #d97706;
background: #fffbeb;
border: 1px solid rgba(217, 119, 6, 0.2);
}
.party-cell {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.4;
}
.party-cell__sub {
font-size: 12px;
color: var(--el-text-color-secondary, #64748b);
}
.pl-contract-expand {
padding: 12px 16px 16px;
background: #f8fffb;
border-top: 1px dashed #a7f3d0;
}
.pl-contract-expand__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
@media (max-width: 960px) {
.pl-contract-expand__grid {
grid-template-columns: 1fr;
}
}
.pl-contract-side {
background: #fff;
border: 1px solid var(--el-border-color, #d9e8e2);
border-radius: 8px;
padding: 12px;
}
.pl-contract-side.is-need {
border-color: rgba(217, 119, 6, 0.35);
background: #fffdf8;
}
.pl-contract-side.is-ok {
border-color: #a7f3d0;
background: #f6fffb;
}
.pl-contract-side.is-exempt {
border-color: #e2e8f0;
background: #f8fafc;
}
.pl-contract-side__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
}
.pl-contract-side__customer {
margin: 0 0 10px;
font-size: 13px;
}
.pl-contract-side__exempt {
margin: 0;
padding: 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
text-align: center;
color: var(--el-text-color-secondary, #64748b);
font-size: 12px;
background: #f8fafc;
}
.pl-contract-side__actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
</style>