Files
ShiErFeng/shierfeng-fastapi-frontend/src/views/master/customer/index.vue
T
yujiangandClaude Opus 5.5 167df4adb8 Feat: 前端改版 Web 端界面与业务作业流程
按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。

- 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、
  测试模式提示条、深色模式配色、窄屏适配
- 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、
  缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择
  MasterRefSelect,以及编号段解析等工具
- 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案
- 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览;
  分配用户对话框标注增减;7 个新增/编辑对话框统一布局
- 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为
  列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变
- 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查
- 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试,
  同步更新业务与工作台契约脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:22 -07:00

638 lines
33 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="app-container master-page master-customer">
<page-header title="客户与现场">
<template #actions>
<el-row :gutter="10" class="mb8 master-toolbar">
<template v-if="isMasterWriteAllowed">
<el-col v-if="userStore.admin === true && !testBatchStore.isTestMode && selectedIds.length" :span="1.5">
<el-button icon="FolderAdd" :disabled="!selectedIds.length" @click="openAdoptionDialog" v-hasPermi="['system:testbatch:adopt']">纳入测试批次</el-button>
</el-col>
<el-col :span="1.5" class="mp-primary-col"><el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['master:customer:add']">新增客户</el-button></el-col>
</template>
<master-excel-actions resource="customer" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
</el-row>
</template>
</page-header>
<section class="mp-kpis" aria-label="客户概况">
<div class="mp-kpi">
<span class="mp-kpi__label">客户</span>
<span class="mp-kpi__value">{{ formatCount(total) }}</span>
<span class="mp-kpi__hint">{{ disabledCustomerCount === undefined ? '合作中与已停用' : `合作中 ${formatCount(Math.max(0, total - disabledCustomerCount))} · 已停用 ${formatCount(disabledCustomerCount)}` }}</span>
</div>
<div class="mp-kpi">
<span class="mp-kpi__label">本页现场</span>
<span class="mp-kpi__value">{{ formatCount(pageTotals.sites) }}</span>
<span class="mp-kpi__hint">产线 {{ formatCount(pageTotals.lines) }} 条 · 隧道 {{ formatCount(pageTotals.tunnels) }} 条</span>
</div>
<div class="mp-kpi">
<span class="mp-kpi__label">本页在用托盘</span>
<span class="mp-kpi__value">{{ formatCount(pageTotals.pallets) }}</span>
<span class="mp-kpi__hint">另有在途 {{ formatCount(pageTotals.transit) }} 托</span>
</div>
<div class="mp-kpi">
<span class="mp-kpi__label">未建现场的客户</span>
<span class="mp-kpi__value" :class="{ 'is-warn': pageTotals.withoutSite }">{{ formatCount(pageTotals.withoutSite) }}</span>
<span class="mp-kpi__hint">建好现场后才能向客户发货</span>
</div>
</section>
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter mp-filter" :model="queryParams" :inline="true" @submit.prevent>
<el-form-item prop="customerName" class="mp-filter__search">
<el-input v-model="queryParams.customerName" placeholder="搜索客户名称" prefix-icon="Search" clearable maxlength="100" @keyup.enter="handleQuery" @clear="handleQuery" />
</el-form-item>
<el-form-item label="编码" prop="customerCode">
<el-input v-model="queryParams.customerCode" placeholder="客户编码" clearable maxlength="64" style="width: 150px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="状态" prop="status" class="mp-filter__select">
<el-select v-model="queryParams.status" placeholder="全部" clearable @change="handleQuery">
<el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:customer:list']">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:customer:list']">重置</el-button>
</el-form-item>
<span class="mp-filter__summary">{{ formatCount(total) }} 个客户 · 本页 {{ formatCount(pageSiteCount) }} 个现场</span>
</el-form>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<div class="mp-workspace" :class="{ 'is-wide-panel': panelRow }">
<div class="mp-workspace__list">
<el-table
class="master-table data-table"
v-loading="loading"
empty-text="暂无数据"
:data="customerList"
:row-class-name="customerRowClass"
@row-click="handleRowClick"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="44" align="center" />
<el-table-column label="客户" min-width="200">
<template #default="scope">
<span class="mp-name-cell">
<span class="mp-initial" :class="{ 'is-off': scope.row.status === '1' }">{{ orgInitial(scope.row.customerName) }}</span>
<span class="mp-name-cell__text">
<strong>{{ scope.row.customerName }}</strong>
<small><span class="mp-mono">{{ scope.row.customerCode }}</span> · {{ scope.row.siteCount ?? 0 }} 个现场</small>
</span>
</span>
</template>
</el-table-column>
<el-table-column label="在用托盘" width="96" align="right">
<template #default="scope">
<span class="customer-pallets">
<strong class="mp-num">{{ formatCount(scope.row.sitePalletCount ?? 0) }}</strong>
<small v-if="scope.row.transitPalletCount">在途 {{ formatCount(scope.row.transitPalletCount) }}</small>
</span>
</template>
</el-table-column>
<el-table-column v-if="!panelRow" label="层级" min-width="170">
<template #default="scope">
<span class="mp-count-cell"><span><strong>{{ scope.row.lineCount ?? 0 }}</strong> 条产线</span><span><strong>{{ scope.row.tunnelCount ?? 0 }}</strong> 条隧道</span></span>
</template>
</el-table-column>
<el-table-column v-if="!panelRow" label="授权用户" width="96" align="right">
<template #default="scope"><span class="mp-num">{{ scope.row.userCount ?? 0 }} 人</span></template>
</el-table-column>
<el-table-column label="状态" prop="status" width="84">
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
</el-table-column>
<el-table-column v-if="isMasterWriteAllowed && !panelRow" label="操作" width="90" align="right" class-name="small-padding fixed-width nowrap-cell">
<template #default="scope">
<el-button
v-if="proxy.$auth.hasPermiAnd(['master:customer:query', 'master:customer:edit'])"
link type="primary" icon="Edit" :disabled="detailLoading" @click="handleUpdate(scope.row)" v-hasPermi="['master:customer:edit']"
>修改</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0" :total="total"
v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList"
/>
</div>
<section v-if="panelRow" class="mp-panel customer-panel" aria-label="客户详情">
<div class="mp-panel__head">
<span class="mp-initial is-lg" :class="{ 'is-off': panelRow.status === '1' }">{{ orgInitial(panelRow.customerName) }}</span>
<div class="mp-panel__title">
<span class="mp-panel__name">
{{ panelRow.customerName }}
<span class="mp-mono mp-muted">{{ panelRow.customerCode }}</span>
<span class="mp-pill" :class="panelRow.status === '1' ? '' : 'is-ok'">{{ panelRow.status === '1' ? '停用' : '正常合作' }}</span>
</span>
<span class="mp-panel__sub">{{ panelRow.remark || '暂无备注' }}</span>
</div>
<span class="customer-panel__actions">
<template v-if="isMasterWriteAllowed">
<el-button
v-if="proxy.$auth.hasPermiAnd(['master:customer:query', 'master:customer:edit'])"
icon="Edit" :disabled="detailLoading" @click="handleUpdate(panelRow)" v-hasPermi="['master:customer:edit']"
>编辑客户</el-button>
</template>
<el-button v-if="sitePath && isMasterWriteAllowed" icon="Plus" @click="goMaster('site', { customerId: panelRow.customerId, customerName: panelRow.customerName, action: 'add' })">新增现场</el-button>
<button type="button" class="mp-panel__close" aria-label="关闭详情" @click="closePanel">×</button>
</span>
</div>
<div v-loading="panelLoading" class="mp-panel__body">
<div class="mp-stats customer-panel__stats">
<div><span>现场</span><strong>{{ panelTotals.sites }}</strong></div>
<div><span>产线</span><strong>{{ panelTotals.lines }}</strong></div>
<div><span>隧道</span><strong>{{ panelTotals.tunnels }}</strong></div>
<div><span>在用托盘</span><strong>{{ formatCount(panelTotals.pallets) }}</strong></div>
<div><span>授权用户</span><strong>{{ panelTotals.users }}</strong></div>
</div>
<template v-if="canQueryCustomer">
<div class="mp-section__head">
<strong>现场</strong>
<button v-if="panelSites.length" type="button" class="mp-link" @click="toggleAllSites">{{ allSitesExpanded ? '全部收起' : '全部展开' }}</button>
</div>
<p v-if="!panelLoading && !panelSites.length" class="mp-empty">还没有现场。现场是发货的目的地,新增后即可安排发货。</p>
<section v-for="site in panelSites" :key="site.siteId" class="customer-site" :class="{ 'is-open': expandedSites.includes(site.siteId) }">
<button type="button" class="customer-site__head" :aria-expanded="expandedSites.includes(site.siteId)" @click="toggleSite(site.siteId)">
<span class="customer-site__chevron" aria-hidden="true">›</span>
<span class="customer-site__title">
<span>
<strong>{{ site.siteName }}</strong>
<span class="mp-mono mp-muted">{{ site.siteCode }}</span>
<span v-if="site.status === '1'" class="mp-pill is-outline">停用</span>
<span v-if="site.emptyPalletCount" class="mp-pill is-warn">空托 {{ site.emptyPalletCount }} 待退回</span>
</span>
<small>{{ site.address || '未登记地址' }}</small>
</span>
<span class="mp-count-cell customer-site__counts">
<span>{{ site.lines.length }} 条产线</span>
<span>{{ site.tunnels.length }} 条隧道</span>
<span><strong>{{ sitePalletCount(site) }}</strong> 托在用</span>
</span>
<span class="mp-avatars" :aria-label="`授权用户 ${site.users.length} 人`">
<span v-for="user in site.users.slice(0, 3)" :key="user.userId" class="mp-avatar" :style="{ '--mp-avatar': avatarTint(user.userId) }" :title="userDisplayName(user)">{{ personInitial(user) }}</span>
<span v-if="site.users.length > 3" class="mp-avatar is-more">+{{ site.users.length - 3 }}</span>
</span>
</button>
<div v-if="expandedSites.includes(site.siteId)" class="customer-site__body">
<div class="customer-site__col">
<span class="customer-site__col-head"><strong>产线</strong><button v-if="linePath && isMasterWriteAllowed" type="button" class="mp-link" @click="goMaster('line', { siteId: site.siteId, siteName: site.siteName, action: 'add' })">+ 新增</button></span>
<span v-for="line in site.lines" :key="line.lineId" class="customer-site__row">
<span>{{ line.lineName }}</span>
<span class="mp-mono mp-muted">{{ line.lineCode }}</span>
<span class="mp-muted mp-num customer-site__note">{{ line.status === '1' ? '停用' : line.activePalletCount ? `${line.activePalletCount} 托在线` : '空闲' }}</span>
</span>
<span v-if="!site.lines.length" class="mp-muted customer-site__none">未登记产线</span>
</div>
<div class="customer-site__col">
<span class="customer-site__col-head"><strong>隧道</strong><button v-if="tunnelPath && isMasterWriteAllowed" type="button" class="mp-link" @click="goMaster('tunnel', { siteId: site.siteId, siteName: site.siteName, action: 'add' })">+ 新增</button></span>
<span v-for="tunnel in site.tunnels" :key="tunnel.tunnelId" class="customer-site__tunnel">
<span class="customer-site__row">
<span class="mp-mono">{{ tunnel.tunnelCode }}</span>
<span>{{ tunnel.tunnelName }}</span>
<span class="mp-pill customer-site__note" :class="tunnel.currentTraceNo ? 'is-site' : ''">{{ tunnel.status === '1' ? '停用' : tunnel.currentTraceNo ? '有物料' : '空闲' }}</span>
</span>
<small v-if="tunnel.currentTraceNo" class="mp-muted mp-num">溯源号 <span class="mp-mono">{{ shortTrace(tunnel.currentTraceNo) }}</span><template v-if="tunnel.currentTraceStartedAt"> · {{ parseTime(tunnel.currentTraceStartedAt, '{m}-{d}') }} 起</template> · {{ tunnel.activePalletCount }} 托</small>
</span>
<span v-if="!site.tunnels.length" class="mp-muted customer-site__none">未登记隧道</span>
</div>
<div class="customer-site__col">
<span class="customer-site__col-head"><strong>现场托盘</strong><span class="mp-muted mp-num">剩余 {{ formatCount(site.remainingPackageQty) }} 包</span></span>
<span class="mp-stack" role="img" :aria-label="`未使用 ${site.unusedPalletCount} 托,部分使用 ${site.partialPalletCount} 托,空托 ${site.emptyPalletCount} 托`">
<span :style="{ flex: site.unusedPalletCount, background: 'var(--sf-site)' }"></span>
<span :style="{ flex: site.partialPalletCount, background: 'var(--sf-transit)' }"></span>
<span :style="{ flex: site.emptyPalletCount, background: 'var(--sf-neutral-dot)' }"></span>
</span>
<span class="mp-legend">
<span><i style="background: var(--sf-site)"></i>未使用<b>{{ site.unusedPalletCount }}</b></span>
<span><i style="background: var(--sf-transit)"></i>部分使用<b>{{ site.partialPalletCount }}</b></span>
<span><i style="background: var(--sf-neutral-dot)"></i>空托<b>{{ site.emptyPalletCount }}</b></span>
</span>
<small v-if="site.transitPalletCount" class="mp-muted mp-num">另有 {{ site.transitPalletCount }} 托在途</small>
<span class="customer-site__users">
<span class="mp-muted">授权用户 {{ site.users.length ? site.users.map(userDisplayName).join('、') : '未分配' }}</span>
<button v-if="sitePath && isMasterWriteAllowed" type="button" class="mp-link" @click="goMaster('site', { siteId: site.siteId, siteName: site.siteName, action: 'assign' })">分配</button>
</span>
</div>
</div>
</section>
</template>
<p v-else class="mp-empty">需要客户查询权限才能查看现场、产线与隧道层级。</p>
</div>
</section>
</div>
<test-batch-adoption-dialog ref="adoptionDialogRef" :root-type="'customer'" :resource-ids="selectedIds" @success="getList" />
<el-dialog
v-model="open"
:title="title"
width="min(620px, 94vw)"
append-to-body
class="sf-form-dialog"
:close-on-click-modal="!submitLoading"
:close-on-press-escape="!submitLoading"
:show-close="!submitLoading"
:before-close="handleDialogBeforeClose"
>
<p class="sf-form-intro">客户是现场的上级。先建客户,再在客户下添加现场,现场才能作为发货目的地。</p>
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
<el-form ref="customerRef" :model="form" :rules="rules" label-position="top" class="sf-form">
<div class="sf-form-grid">
<el-form-item label="客户编码" prop="customerCode">
<el-input v-model="form.customerCode" class="sf-form-mono" placeholder="如 C-0012" maxlength="64" />
<div class="sf-form-hint">全局唯一,导入现场时用它关联客户</div>
</el-form-item>
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="form.customerName" placeholder="对外的客户全称或简称" maxlength="100" />
<div class="sf-form-hint">发货单、报表与客户详情中显示</div>
</el-form-item>
</div>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status" class="sf-choice">
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value" border>
<strong>{{ dict.label }}</strong><small>{{ STATUS_HINTS[dict.value] }}</small>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填,例如结算方式、对接人" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer sf-form-footer">
<span class="sf-form-footer__note">{{ formMetaText }}</span>
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
<el-button v-if="!form.customerId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:customer:add']">创建客户</el-button>
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:customer:edit']">保存修改</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.master-query :deep(.el-form-item) { margin-right:0; } .master-query :deep(.el-form-item__content) { min-width:0; } .master-query :deep(.el-input), .master-query :deep(.el-input-number), .master-query :deep(.el-select) { width:100% !important; min-width:0; }
.master-table :deep(.el-table__cell) { padding:10px 0; } .master-table :deep(.fixed-width) { position:sticky; right:0; z-index:2; background:var(--el-bg-color); }
.customer-pallets { display:inline-flex; flex-direction:column; align-items:flex-end; line-height:1.3; }
.customer-pallets strong { font-weight:600; }
.customer-pallets small { color:var(--sf-text-4, #6b717d); font-size:12px; }
.customer-panel__actions { display:flex; flex:none; align-items:center; gap:8px; }
.customer-panel__actions .el-button + .el-button { margin-left:0; }
.master-customer .customer-panel__stats.mp-stats { grid-template-columns:repeat(5, minmax(0, 1fr)); }
.customer-site { overflow:hidden; border:1px solid var(--sf-border, #e7e9ec); border-radius:10px; }
.customer-site__head { display:flex; align-items:center; gap:12px; width:100%; padding:12px 14px; border:0; background:transparent; color:inherit; font:inherit; text-align:left; cursor:pointer; }
.customer-site__head:hover { background:var(--sf-surface-2, #f7f8f9); }
.customer-site__chevron { flex:none; width:12px; color:var(--sf-text-4, #6b717d); font-size:18px; line-height:1; transition:transform .15s ease; }
.customer-site.is-open .customer-site__chevron { transform:rotate(90deg); }
.customer-site__title { display:flex; flex:1; flex-direction:column; gap:2px; min-width:0; }
.customer-site__title > span { display:flex; flex-wrap:wrap; align-items:center; gap:8px; }
.customer-site__title strong { font-size:14px; font-weight:600; }
.customer-site__title small { overflow:hidden; color:var(--sf-text-4, #6b717d); font-size:12.5px; white-space:nowrap; text-overflow:ellipsis; }
.customer-site__counts { flex:none; }
.customer-site__body { display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:16px 24px; padding:4px 16px 16px 38px; }
.customer-site__col { display:flex; flex-direction:column; gap:8px; min-width:0; }
.customer-site__col-head { display:flex; align-items:baseline; justify-content:space-between; gap:8px; font-size:12.5px; }
.customer-site__col-head strong { color:var(--sf-text-3, #5b616e); font-size:12.5px; font-weight:500; }
.customer-site__row { display:flex; align-items:center; gap:8px; min-width:0; font-size:13px; }
.customer-site__note { margin-left:auto; font-size:12px; }
.customer-site__tunnel { display:flex; flex-direction:column; gap:2px; }
.customer-site__tunnel small, .customer-site__col > small { font-size:12px; }
.customer-site__none { font-size:12.5px; }
.customer-site__users { display:flex; align-items:baseline; justify-content:space-between; gap:8px; padding-top:8px; border-top:1px solid var(--sf-border-soft, #eef0f2); font-size:12.5px; }
@media (max-width:768px) {
.master-customer .customer-panel__stats.mp-stats { grid-template-columns:repeat(3, minmax(0, 1fr)); }
.customer-site__head { flex-wrap:wrap; }
.customer-site__counts { width:100%; padding-left:24px; }
.customer-site__body { padding-left:16px; }
.mp-panel__head { flex-wrap:wrap; }
}
</style>
<script setup name="Customer">
import { addCustomer, getCustomer, listCustomer, updateCustomer } from "@/api/master/customer";
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
import useUserStore from '@/store/modules/user'
import useTestBatchStore from '@/store/modules/testBatch'
import { avatarTint, formatCount, masterRoutePath, orgInitial, personInitial, userDisplayName } from '@/utils/masterDisplay'
const { proxy } = getCurrentInstance();
const userStore = useUserStore()
const testBatchStore = useTestBatchStore()
const isMasterWriteAllowed = computed(() => userStore.admin === true || !testBatchStore.isTestMode)
const adoptionDialogRef = ref()
const { sys_normal_disable } = proxy.useDict("sys_normal_disable");
const customerList = ref([]);
const showSearch = ref(true);
const selectedIds = ref([]);
const loading = ref(false);
const detailLoading = ref(false);
const submitLoading = ref(false);
const loadError = ref("");
const formError = ref("");
const open = ref(false);
const single = ref(true);
const total = ref(0);
const title = ref("");
let listGeneration = 0;
let detailGeneration = 0;
// 状态选项的后果说明,与服务端校验保持一致
const STATUS_HINTS = { '0': '可以在客户下新增现场', '1': '不能再新增现场;已有现场与历史单据保留' };
const formMeta = ref(null);
const formMetaText = computed(() => formMeta.value
? [`最后修改${formMeta.value.by ? ` ${formMeta.value.by}` : ''}`, proxy.parseTime(formMeta.value.time, '{y}-{m}-{d} {h}:{i}')].filter(Boolean).join(' · ')
: '带 * 的为必填项');
const panelRow = ref(null)
const panelDetail = ref(null)
const panelLoading = ref(false)
const expandedSites = ref([])
let panelGeneration = 0
const canQueryCustomer = computed(() => proxy.$auth.hasPermiAnd(['master:customer:query']))
const sitePath = computed(() => masterRoutePath('site'))
const linePath = computed(() => masterRoutePath('line'))
const tunnelPath = computed(() => masterRoutePath('tunnel'))
const pageSiteCount = computed(() => customerList.value.reduce((sum, row) => sum + (row.siteCount || 0), 0))
const disabledCustomerCount = ref(undefined)
let statusCountGeneration = 0
const pageTotals = computed(() => customerList.value.reduce((totals, row) => {
totals.sites += row.siteCount || 0
totals.lines += row.lineCount || 0
totals.tunnels += row.tunnelCount || 0
totals.pallets += row.sitePalletCount || 0
totals.transit += row.transitPalletCount || 0
if (!row.siteCount && row.status !== '1') totals.withoutSite += 1
return totals
}, { sites: 0, lines: 0, tunnels: 0, pallets: 0, transit: 0, withoutSite: 0 }))
const panelSites = computed(() => panelDetail.value?.sites || [])
const allSitesExpanded = computed(() => panelSites.value.length > 0 && panelSites.value.every((site) => expandedSites.value.includes(site.siteId)))
const panelTotals = computed(() => {
const row = panelRow.value || {}
if (!panelDetail.value) {
return { sites: row.siteCount ?? 0, lines: row.lineCount ?? 0, tunnels: row.tunnelCount ?? 0, pallets: row.sitePalletCount ?? 0, users: row.userCount ?? 0 }
}
const users = new Set(panelSites.value.flatMap((site) => site.users.map((user) => user.userId)))
return {
sites: panelSites.value.length,
lines: panelSites.value.reduce((sum, site) => sum + site.lines.length, 0),
tunnels: panelSites.value.reduce((sum, site) => sum + site.tunnels.length, 0),
pallets: panelSites.value.reduce((sum, site) => sum + sitePalletCount(site), 0),
users: users.size
}
})
function validateRequiredText(rule, value, callback) {
if (typeof value !== "string" || value.trim() === "") {
callback(new Error(`${rule.field === "customerCode" ? "客户编码" : "客户名称"}不能为空`));
return;
}
callback();
}
const data = reactive({
form: {},
queryParams: { pageNum: 1, pageSize: 10, customerCode: undefined, customerName: undefined, status: undefined },
rules: {
customerCode: [{ required: true, validator: validateRequiredText, trigger: "blur" }],
customerName: [{ required: true, validator: validateRequiredText, trigger: "blur" }],
status: [{ required: true, message: "请选择状态", trigger: "change" }]
}
});
const { form, queryParams, rules } = toRefs(data);
function isCurrentListRequest(listContext) {
return listContext.generation === listGeneration;
}
function getList() {
const listContext = {
generation: ++listGeneration,
query: { ...queryParams.value }
};
loading.value = true;
loadError.value = "";
listCustomer(listContext.query).then((response) => {
if (!isCurrentListRequest(listContext)) {
return;
}
customerList.value = response.rows;
total.value = response.total;
syncPanel();
loadDisabledCount(listContext.query);
}).catch(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
customerList.value = [];
total.value = 0;
loadError.value = "客户列表加载失败,请稍后重试";
}).finally(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
loading.value = false;
});
}
function reset() {
formError.value = "";
formMeta.value = null;
form.value = { customerId: undefined, customerCode: "", customerName: "", status: "0", remark: "" };
proxy.resetForm("customerRef");
}
function invalidateDetailRequest() {
detailGeneration += 1;
detailLoading.value = false;
}
function isCurrentDetailRequest(detailContext) {
return detailContext.generation === detailGeneration;
}
function cancel() {
if (submitLoading.value) return;
invalidateDetailRequest();
open.value = false;
reset();
}
function handleDialogBeforeClose(done) {
if (submitLoading.value) return;
invalidateDetailRequest();
reset();
done();
}
function handleQuery() { queryParams.value.pageNum = 1; getList(); }
function resetQuery() { proxy.resetForm("queryRef"); handleQuery(); }
function handleSelectionChange(selection) {
selectedIds.value = selection.map((item) => item.customerId);
single.value = selectedIds.value.length !== 1;
}
function openAdoptionDialog() { adoptionDialogRef.value?.open() }
function handleAdd() {
invalidateDetailRequest();
reset();
title.value = "新增客户";
open.value = true;
}
function handleUpdate(row = {}) {
reset();
const customerId = row.customerId || selectedIds.value[0];
const detailContext = {
generation: ++detailGeneration,
resourceId: customerId
};
detailLoading.value = true;
getCustomer(detailContext.resourceId).then((response) => {
if (!isCurrentDetailRequest(detailContext)) {
return;
}
const detail = response.data;
formMeta.value = { by: detail.updateBy || detail.createBy, time: detail.updateTime || detail.createTime };
form.value = {
customerId: detail.customerId,
customerCode: detail.customerCode,
customerName: detail.customerName,
status: detail.status,
remark: detail.remark || ""
};
title.value = "修改客户";
open.value = true;
}).catch(() => {
if (!isCurrentDetailRequest(detailContext)) {
return;
}
loadError.value = "客户详情加载失败,请稍后重试";
}).finally(() => {
if (!isCurrentDetailRequest(detailContext)) {
return;
}
detailLoading.value = false;
});
}
function afterSubmit(response) {
proxy.$modal.msgSuccess(response.msg || "操作成功");
open.value = false;
getList();
}
function submitCreate() {
proxy.$refs.customerRef.validate((valid) => {
if (!valid) return;
submitLoading.value = true;
formError.value = "";
addCustomer({
customerCode: form.value.customerCode.trim(),
customerName: form.value.customerName.trim(),
status: form.value.status,
remark: form.value.remark?.trim() || undefined
}).then(afterSubmit).catch(() => {
formError.value = "新增失败,请根据提示修正后重试";
}).finally(() => { submitLoading.value = false; });
});
}
function submitUpdate() {
proxy.$refs.customerRef.validate((valid) => {
if (!valid) return;
submitLoading.value = true;
formError.value = "";
updateCustomer({
customerId: form.value.customerId,
customerCode: form.value.customerCode.trim(),
customerName: form.value.customerName.trim(),
status: form.value.status,
remark: form.value.remark?.trim() || undefined
}).then(afterSubmit).catch(() => {
formError.value = "修改失败,请根据提示修正后重试";
}).finally(() => { submitLoading.value = false; });
});
}
// 停用客户数随当前筛选条件变化;已按状态筛选时无需再查
function loadDisabledCount(query) {
const generation = ++statusCountGeneration
if (query.status) {
disabledCustomerCount.value = query.status === '1' ? total.value : 0
return
}
listCustomer({ ...query, pageNum: 1, pageSize: 1, status: '1' }).then((response) => {
if (generation === statusCountGeneration) disabledCustomerCount.value = response.total
}).catch(() => {
if (generation === statusCountGeneration) disabledCustomerCount.value = undefined
})
}
// ---------- 客户详情:现场层级 ----------
function customerRowClass({ row }) {
return row.customerId === panelRow.value?.customerId ? 'is-panel-row' : ''
}
function handleRowClick(row, column, event) {
if (column?.type === 'selection' || event?.target?.closest?.('button, a, .el-checkbox')) return
openPanel(row)
}
// 列表刷新后保留当前客户;首次进入时默认打开第一位客户
function syncPanel() {
const current = panelRow.value && customerList.value.find((row) => row.customerId === panelRow.value.customerId)
if (current) {
panelRow.value = current
loadPanelDetail(current, { keepExpanded: true })
} else if (customerList.value.length && window.matchMedia?.('(min-width: 1024px)').matches !== false) {
openPanel(customerList.value[0])
} else {
closePanel()
}
}
function openPanel(row) {
panelRow.value = row
loadPanelDetail(row)
}
function closePanel() {
panelGeneration += 1
panelRow.value = null
panelDetail.value = null
panelLoading.value = false
}
function loadPanelDetail(row, { keepExpanded = false } = {}) {
const generation = ++panelGeneration
if (!keepExpanded) {
panelDetail.value = null
expandedSites.value = []
}
if (!canQueryCustomer.value) return
panelLoading.value = true
getCustomer(row.customerId).then((response) => {
if (generation !== panelGeneration) return
panelDetail.value = response.data || null
if (!keepExpanded && panelSites.value.length) expandedSites.value = [panelSites.value[0].siteId]
}).catch(() => {
if (generation !== panelGeneration) return
panelDetail.value = null
}).finally(() => {
if (generation === panelGeneration) panelLoading.value = false
})
}
function toggleSite(siteId) {
expandedSites.value = expandedSites.value.includes(siteId)
? expandedSites.value.filter((id) => id !== siteId)
: [...expandedSites.value, siteId]
}
function toggleAllSites() {
expandedSites.value = allSitesExpanded.value ? [] : panelSites.value.map((site) => site.siteId)
}
function sitePalletCount(site) {
return (site.unusedPalletCount || 0) + (site.partialPalletCount || 0) + (site.emptyPalletCount || 0)
}
function shortTrace(traceNo) {
const text = String(traceNo || '')
return text.length > 14 ? `${text.slice(0, 6)}…${text.slice(-4)}` : text
}
// 现场、产线、隧道由各自页面维护,这里带着上级 ID 跳转过去
function goMaster(segment, query) {
const path = masterRoutePath(segment)
if (path) proxy.$router.push({ path, query })
}
getList();
</script>