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