Files
shanxia2.0/web-admin/src/views/pl/plan/index.vue
T

1351 lines
43 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-container">
<section class="panel">
<PlPlanTypeTabs
:model-value="activePlanTypeTab"
:tabs="PL_PLAN_LIST_TABS"
@change="handlePlanTypeTabChange"
/>
<!-- 筛选区 -->
<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.followStatus"
placeholder="全部"
clearable
:disabled="activePlanTypeTab === 'pending_arrange'"
>
<el-option
v-for="opt in PL_PLAN_FOLLOW_STATUS_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="需方">
<el-input
v-model="searchForm.customerName"
placeholder="搜索需方客户名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="产品名称">
<el-input
v-model="searchForm.productName"
placeholder="搜索产品名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="车牌号">
<el-input
v-model="searchForm.licensePlate"
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-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增计划
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedPlan"
@click="handleToolbarCopy"
>
<el-icon><CopyDocument /></el-icon>复制
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedPlan || !canArrangeTransport(selectedPlan)"
@click="handleToolbarArrange"
>
<el-icon><Van /></el-icon>
{{ selectedPlan ? arrangeTransportLabel(selectedPlan) : '安排运输' }}
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedPlan || !canWaitNotice(selectedPlan)"
:loading="waitNoticeLoading"
@click="handleToolbarWaitNotice"
>
<el-icon><Bell /></el-icon>等客户通知
</el-button>
<el-dropdown trigger="click" @command="handleToolbarPrintSlip">
<el-button class="btn-ghost">
<el-icon><Printer /></el-icon>打印单据
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
command="inbound"
:disabled="!canBatchPrintSlip(selectedRows, 'inbound').ok"
>
入库单
</el-dropdown-item>
<el-dropdown-item
command="delivery"
:disabled="!canBatchPrintSlip(selectedRows, 'delivery').ok"
>
送(提)货单
</el-dropdown-item>
<el-dropdown-item
command="subpack"
:disabled="!canBatchPrintSlip(selectedRows, 'subpack').ok"
>
分装单
</el-dropdown-item>
<el-dropdown-item divided command="blank-inbound">空白入库单</el-dropdown-item>
<el-dropdown-item command="blank-delivery">空白送(提)货单</el-dropdown-item>
<el-dropdown-item command="blank-subpack">空白分装单</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
class="btn-ghost"
type="danger"
:disabled="!selectedPlan || !canVoidPlan(selectedPlan)"
:loading="voidLoading"
@click="handleToolbarVoid"
>
<el-icon><CircleClose /></el-icon>作废
</el-button>
<el-button
type="danger"
class="pl-plan-paste-create-btn"
@click="pasteVisible = true"
>
<el-icon><DocumentCopy /></el-icon>三方人车快速创建
</el-button>
<el-button
v-if="canForceFollowStatus"
class="btn-ghost"
:disabled="!selectedPlan"
@click="openForceFollowStatusDialog"
>
<el-icon><EditPen /></el-icon>强制改状态
</el-button>
<ListExportDropdown
:export-fn="planApi.export"
:get-filter-params="getPlanExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="planExportColumns"
filename="计划列表.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="fetchData"
@reset-columns="resetToDefault"
/>
</div>
</div>
<!-- 数据表格 -->
<div class="list-body list-wrap">
<div ref="planTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-plan"
class="list-table"
:data="planList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => planStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="planNo" label="计划编号" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="handleView(row)">{{ row.planNo }}</el-link>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('followStatus')"
prop="followStatus"
label="跟进状态"
min-width="100"
align="center"
>
<template #default="{ row }">
<el-tag :type="getPlPlanFollowStatusTagType(row.followStatus)" size="small">
{{ getPlPlanFollowStatusLabel(row.followStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('planDate')"
prop="planDate"
label="计划日期"
min-width="120"
align="center"
/>
<el-table-column
v-if="isColumnVisible('planType')"
prop="planType"
label="单据类型"
min-width="100"
align="center"
>
<template #default="{ row }">
{{ getPlanTypeLabel(row.planType) }}
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('supplierName')"
prop="supplierName"
label="供方"
min-width="200"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('customerName')"
prop="customerName"
label="需方"
min-width="200"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('licensePlate')"
prop="licensePlate"
label="车牌号"
min-width="160"
show-overflow-tooltip
align="center"
>
<template #default="{ row }">
{{ formatPlVehiclePlateDisplay(row.licensePlate, row.trailerNo) }}
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productName')"
label="产品名称"
min-width="240"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
>
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" field="name" />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productSpec')"
label="型号/规格"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" field="model" />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productPackageSpec')"
label="包装规格"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" field="packageSpec" />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productUnit')"
label="单位"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" field="unit" />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productPlanQty')"
label="计划数量"
min-width="100"
align="right"
header-align="right"
>
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" field="planQty" />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productActualQty')"
label="实际数量"
min-width="100"
align="right"
header-align="right"
>
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" field="actualQty" />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('planAttachments')"
label="计划附件"
min-width="200"
align="left"
header-align="left"
>
<template #default="{ row }">
<PlPlanListCertCell :products="row.products" compact hide-product />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('personnelSummary')"
prop="personnelSummary"
label="人员"
min-width="180"
show-overflow-tooltip
align="center"
>
<template #default="{ row }">
{{ normalizePlPersonnelSummaryDisplay(row.personnelSummary) }}
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('followUserName')"
prop="followUserName"
label="跟进人"
min-width="100"
align="center"
/>
<el-table-column
v-if="isColumnVisible('contractSummary')"
label="合同"
min-width="220"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
>
<template #default="{ row }">
<div class="pl-plan-contract-cell">
<div
v-for="(line, index) in planContractLines(row)"
:key="`${line.side}-${line.no}-${index}`"
class="pl-plan-contract-cell__row"
>
<span class="pl-plan-contract-cell__side">{{ line.side }}</span>
<span
class="pl-plan-contract-cell__no"
:class="{ 'is-pending': line.pending }"
>{{ line.no }}</span>
</div>
</div>
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="planStatsRowRef"
storage-key="pl-plan-column-stats"
:columns="planStatsRowColumns"
:data="planList"
:table-wrap-el="planTableWrapRef"
:get-cell-value="getPlanStatCellValue"
/>
</div>
<div class="pagination list-pager-bar pl-plan-list-pager-bar">
<div class="list-pager-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 100, 500]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</section>
<PlPlanFormDialog
v-model:visible="formDialogVisible"
:plan-id="editingPlanId"
:copy-from-plan-id="copyFromPlanId"
@success="handlePlanMutationSuccess"
/>
<PlPlanViewDialog
ref="viewDialogRef"
v-model:visible="viewVisible"
:plan-id="viewPlanId"
>
<template v-if="viewingPlan" #hero-actions>
<el-button
v-if="canEditPlan(viewingPlan)"
class="btn-main"
type="primary"
@click="handleEdit(viewingPlan)"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-ghost" @click="handleCopy(viewingPlan)">
<el-icon><CopyDocument /></el-icon>复制
</el-button>
<el-button
v-if="canArrangeTransport(viewingPlan)"
class="btn-ghost"
@click="handleArrange(viewingPlan)"
>
<el-icon><Van /></el-icon>{{ arrangeTransportLabel(viewingPlan) }}
</el-button>
<el-button
v-if="canWaitNotice(viewingPlan)"
class="btn-ghost"
:loading="waitNoticeLoading"
@click="handleWaitNotice(viewingPlan)"
>
<el-icon><Bell /></el-icon>等客户通知
</el-button>
<el-button
v-if="canCancelWaitNotice(viewingPlan)"
class="btn-ghost"
:loading="waitNoticeLoading"
@click="handleCancelWaitNotice(viewingPlan)"
>
<el-icon><RefreshLeft /></el-icon>取消等通知
</el-button>
<el-dropdown trigger="click" @command="handleViewPrintSlipCommand">
<el-button class="btn-ghost">
<el-icon><Printer /></el-icon>打印单据
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
command="inbound"
:disabled="!canPrintSlipCommand(viewingPlan?.planType, 'inbound')"
>
入库单
</el-dropdown-item>
<el-dropdown-item
command="delivery"
:disabled="!canPrintSlipCommand(viewingPlan?.planType, 'delivery')"
>
送(提)货单
</el-dropdown-item>
<el-dropdown-item
command="subpack"
:disabled="!canPrintSlipCommand(viewingPlan?.planType, 'subpack')"
>
分装单
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="canVoidPlan(viewingPlan)"
class="btn-ghost"
type="danger"
:loading="voidLoading"
@click="handleVoid(viewingPlan)"
>
<el-icon><CircleClose /></el-icon>作废
</el-button>
</template>
</PlPlanViewDialog>
<PlPlanArrangeDialog
v-model:visible="arrangeDialogVisible"
:plan-id="arrangePlanId"
@success="handlePlanMutationSuccess"
/>
<PlInboundSlipPrintDialog ref="inboundSlipPrintRef" />
<PlDeliverySlipPrintDialog ref="deliverySlipPrintRef" />
<PlSubpackSlipPrintDialog ref="subpackSlipPrintRef" />
<PlPasteRecognizeDialog v-model:visible="pasteVisible" mode="vehicle-bundle" />
<el-dialog
v-model="forceFollowDialogVisible"
title="强制修改跟进状态"
width="640px"
align-center
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
destroy-on-close
@closed="resetForceFollowDialog"
>
<el-form label-position="top" class="form-grid-2">
<el-form-item label="计划编号" style="margin-bottom: 0">
<span>{{ selectedPlan?.planNo || '—' }}</span>
</el-form-item>
<el-form-item label="单据类型" style="margin-bottom: 0">
<span>{{ getPlanTypeLabel(selectedPlan?.planType) }}</span>
</el-form-item>
<el-form-item label="供方" style="margin-bottom: 0">
<span>{{ selectedPlan?.supplierName || '—' }}</span>
</el-form-item>
<el-form-item label="需方" style="margin-bottom: 0">
<span>{{ selectedPlan?.customerName || '—' }}</span>
</el-form-item>
<el-form-item label="产品信息" class="form-col-span-2" style="margin-bottom: 0">
<PlPlanListProductCell :products="selectedPlan?.products || []" />
</el-form-item>
<el-form-item label="当前跟进状态" style="margin-bottom: 0">
<el-tag
v-if="selectedPlan"
:type="getPlPlanFollowStatusTagType(selectedPlan.followStatus)"
size="small"
>
{{ getPlPlanFollowStatusLabel(selectedPlan.followStatus) }}
</el-tag>
<span v-else>—</span>
</el-form-item>
<el-form-item label="目标跟进状态" required style="margin-bottom: 0">
<el-select v-model="forceFollowStatusValue" placeholder="请选择" style="width: 100%">
<el-option
v-for="opt in PL_PLAN_FOLLOW_STATUS_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<p class="form-tip form-col-span-2">仅修改跟进状态字段,不会联动作废或运输安排。</p>
</el-form>
<template #footer>
<el-button @click="forceFollowDialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="forceFollowLoading" @click="submitForceFollowStatus">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import {
ArrowDown,
Bell,
Check,
CircleClose,
Close,
CopyDocument,
DocumentCopy,
Edit,
EditPen,
Plus,
Printer,
RefreshLeft,
Search,
Van
} from '@element-plus/icons-vue'
import { fetchGetMenuCodes } from '@/api/auth'
import { planApi, type Plan } from '@/api/pl'
import PlInboundSlipPrintDialog from '@/components/business/pl-inbound-slip-print-dialog/index.vue'
import PlDeliverySlipPrintDialog from '@/components/business/pl-delivery-slip-print-dialog/index.vue'
import PlSubpackSlipPrintDialog from '@/components/business/pl-subpack-slip-print-dialog/index.vue'
import PlPlanArrangeDialog from '@/components/business/pl-plan-arrange-dialog/index.vue'
import PlPasteRecognizeDialog from '@/components/business/pl-paste-recognize-dialog/index.vue'
import PlPlanFormDialog from '@/components/business/pl-plan-form-dialog/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/index.vue'
import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue'
import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue'
import PlPlanTypeTabs from '@/components/business/pl-plan-type-tabs/index.vue'
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { useListDateShortcut } from '@/composables/useListDateShortcut'
import { canBatchPrintSlip } from '@/utils/pl-batch-print'
import { planContractLines } from '@/utils/pl-plan-contract-display'
import {
canPrintSlipCommand,
formatPlVehiclePlateDisplay,
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType,
normalizePlPersonnelSummaryDisplay,
PL_PLAN_FOLLOW_STATUS_OPTIONS,
PL_PLAN_LIST_TABS,
plPlanListTabToFilters,
type PlPlanListTab,
type PlPrintSlipCommand
} from '@/utils/pl-display'
import { syncViewRowFromFlatList } from '@/utils/view-dialog-refresh'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const voidLoading = ref(false)
const waitNoticeLoading = ref(false)
const canForceFollowStatus = ref(false)
const forceFollowDialogVisible = ref(false)
const forceFollowLoading = ref(false)
const forceFollowStatusValue = ref('')
const formDialogVisible = ref(false)
const pasteVisible = ref(false)
const editingPlanId = ref('')
const copyFromPlanId = ref('')
const viewVisible = ref(false)
const viewPlanId = ref('')
const viewDialogRef = ref<InstanceType<typeof PlPlanViewDialog>>()
const arrangeDialogVisible = ref(false)
const arrangePlanId = ref('')
const inboundSlipPrintRef = ref<InstanceType<typeof PlInboundSlipPrintDialog>>()
const deliverySlipPrintRef = ref<InstanceType<typeof PlDeliverySlipPrintDialog>>()
const subpackSlipPrintRef = ref<InstanceType<typeof PlSubpackSlipPrintDialog>>()
const planList = ref<Plan[]>([])
const listColumnDefs = [
{ prop: 'followStatus', label: '跟进状态' },
{ prop: 'planDate', label: '计划日期' },
{ prop: 'planType', label: '单据类型' },
{ prop: 'supplierName', label: '供方' },
{ prop: 'customerName', label: '需方' },
{ prop: 'licensePlate', label: '车牌号' },
{ prop: 'productName', label: '产品名称' },
{ prop: 'productSpec', label: '型号/规格' },
{ prop: 'productPackageSpec', label: '包装规格' },
{ prop: 'productUnit', label: '单位' },
{ prop: 'productPlanQty', label: '计划数量' },
{ prop: 'productActualQty', label: '实际数量' },
{ prop: 'planAttachments', label: '计划附件' },
{ prop: 'personnelSummary', label: '人员' },
{ prop: 'followUserName', label: '跟进人', defaultVisible: false },
{ prop: 'contractSummary', label: '合同' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('pl-plan-list-columns-v2', listColumnDefs)
const planTableWrapRef = ref<HTMLElement | null>(null)
const planStatsRowRef = ref<{ resync?: () => void } | null>(null)
const planStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'planNo', interactive: true, prop: 'planNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop
})
}
return cols
})
function getPlanStatCellValue(row: Plan, prop: string): unknown {
if (prop === 'followStatus') return getPlPlanFollowStatusLabel(row.followStatus)
if (prop === 'planType') return getPlanTypeLabel(row.planType)
if (prop === 'contractSummary') {
return planContractLines(row)
.map((l) => `${l.side}${l.no}`)
.join(' ')
}
if (prop === 'productPlanQty') {
return (row.products || []).reduce((sum, p) => sum + (Number(p.quantity) || 0), 0)
}
if (prop === 'productActualQty') {
return (row.products || []).reduce((sum, p) => sum + (Number(p.actualQty) || 0), 0)
}
if (prop === 'productName') {
return (row.products || []).map((p) => p.productName || '').filter(Boolean).join('\n')
}
if (prop === 'productSpec') {
return (row.products || []).map((p) => p.model || p.spec || '').filter(Boolean).join('\n')
}
if (prop === 'productPackageSpec') {
return (row.products || []).map((p) => p.packageSpec || '').filter(Boolean).join('\n')
}
if (prop === 'productUnit') {
return (row.products || []).map((p) => p.unit || '').filter(Boolean).join('\n')
}
if (prop === 'planAttachments') {
return ''
}
if (prop === 'licensePlate') {
return formatPlVehiclePlateDisplay(row.licensePlate, row.trailerNo)
}
if (prop === 'personnelSummary') {
return normalizePlPersonnelSummaryDisplay(row.personnelSummary)
}
return (row as any)?.[prop]
}
const selectedRows = ref<Plan[]>([])
function getPlanExportFilterParams() {
const keyword = quickSearch.value.trim()
return {
q: keyword || undefined,
planNo: keyword ? undefined : searchForm.planNo || undefined,
planType: tabFilters.planType || undefined,
followStatus: tabFilters.followStatus || searchForm.followStatus || undefined,
customerName: keyword ? undefined : searchForm.customerName || undefined,
productName: keyword ? undefined : searchForm.productName || undefined,
licensePlate: keyword ? undefined : searchForm.licensePlate || undefined,
planDateFrom: dateRange.value?.[0] || undefined,
planDateTo: dateRange.value?.[1] || undefined
}
}
const planExportColumns = computed(() => {
const cols = [...visibleColumns.value]
if (!cols.includes('planNo')) cols.unshift('planNo')
return cols
})
const viewingPlan = ref<Plan | null>(null)
function syncViewingPlanFromList() {
syncViewRowFromFlatList(viewingPlan, planList.value)
}
async function refreshViewIfOpen() {
if (!viewVisible.value || !viewPlanId.value) return
syncViewingPlanFromList()
await viewDialogRef.value?.reload()
}
function getPlanArrangePrerequisiteGaps(plan: Plan): string[] {
const missing: string[] = []
if (!String(plan.createDate || '').trim()) missing.push('创建日期')
if (!String(plan.planDate || '').trim()) missing.push('计划日期')
if (!String(plan.belongsCompany || '').trim()) missing.push('归属公司')
if (!String(plan.planType || '').trim()) missing.push('单据类型')
if (!String(plan.followUserId || '').trim() && !String(plan.followUserName || '').trim()) {
missing.push('跟进人')
}
if (!String(plan.supplierId || '').trim() || !String(plan.supplierName || '').trim()) {
missing.push('供方客户')
}
if (!String(plan.customerId || '').trim() || !String(plan.customerName || '').trim()) {
missing.push('需方客户')
}
return missing
}
/** 安排运输前校验必填项;缺失则提示并打开编辑弹窗 */
async function ensurePlanReadyForArrange(plan: Plan): Promise<boolean> {
const missing = getPlanArrangePrerequisiteGaps(plan)
if (!missing.length) return true
try {
await ElMessageBox.alert(
`当前运输计划缺少:${missing.join('、')}。请先编辑完善后再安排运输。`,
'请先编辑运输计划',
{ type: 'warning', confirmButtonText: '去编辑' }
)
} catch {
// 关闭弹窗仍跳转编辑
}
if (viewVisible.value && viewingPlan.value?.id === plan.id) {
viewVisible.value = false
}
handleEdit(plan)
return false
}
async function handlePlanMutationSuccess(payload?: { planId?: string; openArrange?: boolean }) {
await fetchData()
await refreshViewIfOpen()
if (payload?.openArrange && payload.planId) {
let plan = planList.value.find(item => item.id === payload.planId)
if (!plan) {
try {
const res = await planApi.get(payload.planId)
plan = (res.data ?? res) as Plan
} catch {
ElMessage.error('获取运输计划失败,无法安排运输')
return
}
}
if (!(await ensurePlanReadyForArrange(plan))) return
arrangePlanId.value = payload.planId
arrangeDialogVisible.value = true
}
}
const selectedPlan = computed(() => (selectedRows.value.length === 1 ? selectedRows.value[0] : null))
const dateRange = ref<string[]>([])
const quickSearch = ref('')
const activePlanTypeTab = ref<PlPlanListTab>('all')
const searchForm = reactive({
planNo: '',
planType: '',
followStatus: '',
customerName: '',
productName: '',
licensePlate: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
function getPlanTypeLabel(type?: string | null) {
if (!type) return '—'
const map: Record<string, string> = { direct: '直销单', inbound: '入库单', outbound: '出库单' }
return map[type] || type
}
async function fetchData() {
loading.value = true
try {
const keyword = quickSearch.value.trim()
const tabFilters = plPlanListTabToFilters(activePlanTypeTab.value)
const res = await planApi.list({
q: keyword || undefined,
planNo: keyword ? undefined : searchForm.planNo || undefined,
planType: tabFilters.planType || undefined,
followStatus: tabFilters.followStatus || searchForm.followStatus || undefined,
customerName: keyword ? undefined : searchForm.customerName || undefined,
productName: keyword ? undefined : searchForm.productName || undefined,
licensePlate: keyword ? undefined : searchForm.licensePlate || undefined,
planDateFrom: dateRange.value?.[0] || undefined,
planDateTo: dateRange.value?.[1] || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
const data = res.data ?? res
planList.value = data.list || []
pagination.total = data.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handlePlanTypeTabChange(tab: PlPlanListTab | string) {
const nextTab = tab as PlPlanListTab
const prevTab = activePlanTypeTab.value
activePlanTypeTab.value = nextTab
const filters = plPlanListTabToFilters(nextTab)
searchForm.planType = filters.planType
if (nextTab === 'pending_arrange') {
searchForm.followStatus = 'pending_arrange'
} else if (prevTab === 'pending_arrange') {
searchForm.followStatus = ''
}
pagination.page = 1
fetchData()
}
function handleReset() {
activePlanTypeTab.value = 'all'
searchForm.planNo = ''
searchForm.planType = ''
searchForm.followStatus = ''
searchForm.customerName = ''
searchForm.productName = ''
searchForm.licensePlate = ''
quickSearch.value = ''
resetDateShortcut()
pagination.page = 1
fetchData()
}
function handleQuickSearch() {
searchForm.planNo = ''
searchForm.customerName = ''
searchForm.productName = ''
searchForm.licensePlate = ''
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 handleAdd() {
editingPlanId.value = ''
copyFromPlanId.value = ''
formDialogVisible.value = true
}
function handleSelectionChange(selection: Plan[]) {
selectedRows.value = selection
}
function handleView(row: Plan) {
viewingPlan.value = row
viewPlanId.value = row.id
viewVisible.value = true
}
function handleEdit(row: Plan) {
editingPlanId.value = row.id
copyFromPlanId.value = ''
formDialogVisible.value = true
}
function handleCopy(row: Plan) {
editingPlanId.value = ''
copyFromPlanId.value = row.id
formDialogVisible.value = true
}
function handleToolbarCopy() {
if (!selectedPlan.value) return
handleCopy(selectedPlan.value)
}
function canEditPlan(row: Plan) {
return row.archiveStatus !== 'archived' && row.followStatus !== 'cancelled'
}
function canArrangeTransport(row: Plan) {
return (
(row.transportStatus === 'pending' || row.transportStatus === 'arranged') &&
row.archiveStatus !== 'archived' &&
row.followStatus !== 'cancelled'
)
}
function arrangeTransportLabel(row: Plan) {
return row.transportStatus === 'arranged' ? '调整运输' : '安排运输'
}
function canVoidPlan(row: Plan) {
return row.archiveStatus !== 'archived' && row.followStatus !== 'cancelled'
}
function canWaitNotice(row: Plan) {
return (
row.followStatus === 'pending_arrange' &&
row.archiveStatus !== 'archived'
)
}
function canCancelWaitNotice(row: Plan) {
return row.followStatus === 'wait_notice' && row.archiveStatus !== 'archived'
}
function syncViewingPlan(updated: Plan) {
if (viewingPlan.value?.id === updated.id) {
viewingPlan.value = { ...viewingPlan.value, ...updated }
}
}
async function handleWaitNotice(row: Plan) {
waitNoticeLoading.value = true
try {
const res = await planApi.waitNotice(row.id)
const data = (res as any)?.data ?? res
ElMessage.success('已标记为等客户通知')
if (data?.id) syncViewingPlan(data as Plan)
fetchData()
} catch (e) {
const message = e instanceof Error ? e.message : '操作失败'
ElMessage.error(message)
} finally {
waitNoticeLoading.value = false
}
}
async function handleCancelWaitNotice(row: Plan) {
waitNoticeLoading.value = true
try {
const res = await planApi.cancelWaitNotice(row.id)
const data = (res as any)?.data ?? res
ElMessage.success('已取消等通知,恢复为待安排')
if (data?.id) syncViewingPlan(data as Plan)
fetchData()
} catch (e) {
const message = e instanceof Error ? e.message : '操作失败'
ElMessage.error(message)
} finally {
waitNoticeLoading.value = false
}
}
function handleToolbarWaitNotice() {
if (!selectedPlan.value) return
handleWaitNotice(selectedPlan.value)
}
async function handleVoid(row: Plan) {
const hint =
row.transportStatus === 'arranged'
? '作废后将取消运输安排、作废关联入库/出库单与地磅出入库单,现场链接将失效。是否继续?'
: '确认作废该运输计划吗?作废后将无法恢复。'
await ElMessageBox.confirm(hint, '作废运输计划', { type: 'warning' })
voidLoading.value = true
try {
await planApi.cancel(row.id)
ElMessage.success('作废成功')
fetchData()
} catch (e) {
const message = e instanceof Error ? e.message : '作废失败'
ElMessage.error(message)
} finally {
voidLoading.value = false
}
}
async function handleArrange(row: Plan) {
if (!(await ensurePlanReadyForArrange(row))) return
arrangePlanId.value = row.id
arrangeDialogVisible.value = true
}
function handlePrintSlip(command: PlPrintSlipCommand, row: Plan) {
if (!canPrintSlipCommand(row.planType, command)) {
ElMessage.warning('当前单据类型不支持打印该单据')
return
}
if (command === 'inbound') {
inboundSlipPrintRef.value?.open(row.id)
return
}
if (command === 'delivery') {
deliverySlipPrintRef.value?.open(row.id)
return
}
subpackSlipPrintRef.value?.open(row.id)
}
function handleViewPrintSlipCommand(command: string) {
if (!viewingPlan.value) return
handlePrintSlip(command as PlPrintSlipCommand, viewingPlan.value)
}
function handleToolbarPrintSlip(command: string) {
if (command === 'blank-inbound') {
inboundSlipPrintRef.value?.openBlank()
return
}
if (command === 'blank-delivery') {
deliverySlipPrintRef.value?.openBlank()
return
}
if (command === 'blank-subpack') {
subpackSlipPrintRef.value?.openBlank()
return
}
const cmd = command as PlPrintSlipCommand
const plans = selectedRows.value
const check = canBatchPrintSlip(plans, cmd)
if (!check.ok) {
ElMessage.warning(check.reason || '无法打印')
return
}
const ids = plans.map((p) => p.id)
if (cmd === 'inbound') {
inboundSlipPrintRef.value?.open(ids)
return
}
if (cmd === 'delivery') {
deliverySlipPrintRef.value?.open(ids)
return
}
subpackSlipPrintRef.value?.open(ids)
}
async function handleToolbarArrange() {
if (selectedRows.value.length !== 1) return
await handleArrange(selectedRows.value[0])
}
function handleToolbarVoid() {
if (selectedRows.value.length !== 1) return
handleVoid(selectedRows.value[0])
}
function openForceFollowStatusDialog() {
if (!selectedPlan.value) return
forceFollowStatusValue.value = selectedPlan.value.followStatus || ''
forceFollowDialogVisible.value = true
}
function resetForceFollowDialog() {
forceFollowStatusValue.value = ''
}
async function submitForceFollowStatus() {
if (!selectedPlan.value) return
const next = (forceFollowStatusValue.value || '').trim()
if (!next) {
ElMessage.warning('请选择目标跟进状态')
return
}
if (next === selectedPlan.value.followStatus) {
ElMessage.warning('目标状态与当前状态相同')
return
}
forceFollowLoading.value = true
try {
await planApi.forceFollowStatus(selectedPlan.value.id, next)
ElMessage.success('跟进状态已更新')
forceFollowDialogVisible.value = false
fetchData()
} catch (e) {
const message = e instanceof Error ? e.message : '修改失败'
ElMessage.error(message)
} finally {
forceFollowLoading.value = false
}
}
async function loadForceFollowPermission() {
try {
const perm = await fetchGetMenuCodes()
canForceFollowStatus.value =
!!perm?.all ||
!!perm?.isSuperAdmin ||
(perm?.menuCodes || []).includes('pl:plan:force-follow-status')
} catch {
canForceFollowStatus.value = false
}
}
useDbInstantSearch(
() => ({
planNo: searchForm.planNo,
planType: searchForm.planType,
followStatus: searchForm.followStatus,
customerName: searchForm.customerName,
productName: searchForm.productName,
licensePlate: searchForm.licensePlate,
dateRange: dateRange.value
}),
() => {
quickSearch.value = ''
handleSearch()
}
)
onMounted(() => {
loadForceFollowPermission()
fetchData()
openArrangeFromQuery()
})
async function openArrangeFromQuery() {
const arrangeId = route.query.arrange as string | undefined
if (!arrangeId) return
router.replace({ path: '/pl/plan' })
try {
const res = await planApi.get(arrangeId)
const plan = (res.data ?? res) as Plan
await handleArrange(plan)
} catch {
ElMessage.error('获取运输计划失败,无法安排运输')
}
}
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.pl-plan-list-toolbar-row {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
> .list-toolbar-date-shortcuts,
> .quick-search-input,
> .quick-search-group,
> .el-button,
> .el-dropdown {
flex-shrink: 0;
}
/* 工具栏全局把按钮刷成描边,需单独盖住才能显示危险实心红 */
> .el-button.pl-plan-paste-create-btn {
color: #fff !important;
background: var(--danger, #dc2626) !important;
border-color: var(--danger, #dc2626) !important;
&:hover,
&:focus {
color: #fff !important;
background: #b91c1c !important;
border-color: #b91c1c !important;
}
}
}
.pl-plan-list-pager-bar {
justify-content: space-between;
}
.list-pager-selected {
display: inline-flex;
align-items: center;
flex-shrink: 0;
min-height: 32px;
margin-right: auto;
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
.sel-num {
margin: 0 2px;
color: var(--mint-primary-dark, #059669);
font-weight: 700;
}
}
.pl-plan-contract-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
text-align: left;
}
.pl-plan-contract-cell__row {
display: flex;
align-items: baseline;
gap: 6px;
width: 100%;
min-width: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text, #1e293b);
}
.pl-plan-contract-cell__side {
flex-shrink: 0;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
}
.pl-plan-contract-cell__no {
min-width: 0;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
&.is-pending {
font-weight: 600;
color: #d97706;
}
}
</style>