Feat: 前端改版 Web 端界面与业务作业流程

按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 05:03:22 -07:00
co-authored by Claude Opus 5.5
parent 3050ccdc4e
commit 167df4adb8
71 changed files with 18396 additions and 2428 deletions
@@ -1,14 +1,52 @@
<template>
<div class="app-container master-page">
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter" :model="queryParams" :inline="true">
<el-form-item label="客户编码" prop="customerCode">
<el-input v-model="queryParams.customerCode" placeholder="请输入客户编码" clearable maxlength="64" @keyup.enter="handleQuery" />
<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="customerName">
<el-input v-model="queryParams.customerName" placeholder="请输入客户名称" clearable maxlength="100" @keyup.enter="handleQuery" />
<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">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 160px">
<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>
@@ -16,86 +54,220 @@
<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-row :gutter="10" class="mb8 master-toolbar">
<template v-if="isMasterWriteAllowed">
<el-col :span="1.5"><el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['master:customer:add']">新增</el-button></el-col>
<el-col :span="1.5">
<el-button
v-if="proxy.$auth.hasPermiAnd(['master:customer:query', 'master:customer:edit'])"
type="success" plain icon="Edit" :disabled="single || detailLoading" :loading="detailLoading"
@click="handleUpdate()" v-hasPermi="['master:customer:edit']"
>修改</el-button>
</el-col>
<el-col v-if="userStore.admin === true && !testBatchStore.isTestMode" :span="1.5">
<el-button type="warning" plain icon="FolderAdd" :disabled="!selectedIds.length" @click="openAdoptionDialog" v-hasPermi="['system:testbatch:adopt']">纳入测试批次</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>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table class="master-table data-table" v-loading="loading" empty-text="暂无数据" :data="customerList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="客户ID" prop="customerId" align="center" />
<el-table-column label="客户编码" prop="customerCode" align="center" :show-overflow-tooltip="true" />
<el-table-column label="客户名称" prop="customerName" align="center" :show-overflow-tooltip="true" />
<el-table-column label="状态" prop="status" align="center" width="100">
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
</el-table-column>
<el-table-column label="创建时间" prop="createTime" align="center" width="180">
<template #default="scope"><span>{{ parseTime(scope.row.createTime) }}</span></template>
</el-table-column>
<el-table-column v-if="isMasterWriteAllowed" label="操作" align="center" class-name="small-padding fixed-width">
<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>
<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>
<pagination
v-show="total > 0" :total="total"
v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList"
/>
<test-batch-adoption-dialog ref="adoptionDialogRef" :root-type="'customer'" :resource-ids="selectedIds" @success="getList" />
<el-dialog
v-model="open"
:title="title"
width="min(520px, 92vw)"
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-width="90px">
<el-form-item label="客户编码" prop="customerCode">
<el-input v-model="form.customerCode" placeholder="请输入客户编码" maxlength="64" show-word-limit />
</el-form-item>
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="form.customerName" placeholder="请输入客户名称" maxlength="100" show-word-limit />
</el-form-item>
<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">
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
<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-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">
<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>
<el-button :disabled="submitLoading" @click="cancel">取 消</el-button>
<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>
@@ -103,17 +275,41 @@
</template>
<style scoped>
.master-query { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:0 14px; padding:16px 18px 4px; margin-bottom:14px; background:var(--el-bg-color); border:1px solid var(--el-border-color-lighter); border-radius:8px; }
.master-query :deep(.el-form-item) { margin-right:0; width:100%; }
.master-query :deep(.el-form-item__content) { flex:1; 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-query :deep(.el-form-item:last-child) { align-self:end; }
.master-toolbar { display:flex; flex-wrap:wrap; gap:8px; }
.master-toolbar :deep(.el-col) { width:auto; }
.master-table :deep(.el-table__header th) { background:var(--el-fill-color-light); font-weight:600; }
.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); box-shadow:-4px 0 8px rgba(0,0,0,.04); }
@media (max-width:768px) { .master-query { grid-template-columns:1fr; padding:14px 14px 2px; } .master-query :deep(.el-form-item:last-child) { margin-bottom:12px; } .master-toolbar :deep(.el-button) { margin-left:0; } }
.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">
@@ -121,6 +317,7 @@ import { addCustomer, getCustomer, listCustomer, updateCustomer } from "@/api/ma
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()
@@ -142,6 +339,50 @@ 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() === "") {
@@ -179,6 +420,8 @@ function getList() {
}
customerList.value = response.rows;
total.value = response.total;
syncPanel();
loadDisabledCount(listContext.query);
}).catch(() => {
if (!isCurrentListRequest(listContext)) {
return;
@@ -196,6 +439,7 @@ function getList() {
function reset() {
formError.value = "";
formMeta.value = null;
form.value = { customerId: undefined, customerCode: "", customerName: "", status: "0", remark: "" };
proxy.resetForm("customerRef");
}
@@ -246,6 +490,7 @@ function handleUpdate(row = {}) {
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,
@@ -304,5 +549,89 @@ function submitUpdate() {
}).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>