Files
ShiErFeng/shierfeng-fastapi-frontend/src/views/master/tunnel/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

467 lines
21 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-tunnel">
<page-header>
<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:tunnel:add']">新增隧道</el-button></el-col>
</template>
<master-excel-actions resource="tunnel" :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">{{ routeFilter.siteName ? `现场:${routeFilter.siteName}` : '全部现场' }}</span>
</div>
<div class="mp-kpi">
<span class="mp-kpi__label">本页有物料</span>
<span class="mp-kpi__value">{{ formatCount(pageTotals.loaded) }}</span>
<span class="mp-kpi__hint">空闲 {{ formatCount(pageTotals.idle) }} 条</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.packages) }} 包</span>
</div>
<div class="mp-kpi">
<span class="mp-kpi__label">本页近 30 天使用</span>
<span class="mp-kpi__value">{{ formatCount(pageTotals.usages) }}<small>次</small></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="tunnelName" class="mp-filter__search">
<el-input v-model="queryParams.tunnelName" placeholder="搜索隧道名称" prefix-icon="Search" clearable maxlength="100" @keyup.enter="handleQuery" @clear="handleQuery" />
</el-form-item>
<el-form-item label="编号" prop="tunnelCode">
<el-input v-model="queryParams.tunnelCode" placeholder="隧道编号" clearable maxlength="64" style="width: 150px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="现场ID" prop="siteId">
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="全部" style="width: 110px" @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:tunnel:list']">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:tunnel:list']">重置</el-button>
</el-form-item>
<span v-if="queryParams.siteId && routeFilter.siteName" class="master-filter-chip">
现场:{{ routeFilter.siteName }}
<button type="button" aria-label="清除现场筛选" @click="clearRouteFilter">×</button>
</span>
<span class="mp-filter__summary">共 {{ formatCount(total) }} 条隧道</span>
</el-form>
<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="tunnelList" :row-class-name="tunnelRowClass" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="44" align="center" />
<el-table-column label="隧道" min-width="160">
<template #default="scope">
<span class="mp-name-cell__text">
<strong>{{ scope.row.tunnelName }}</strong>
<small class="mp-mono">{{ scope.row.tunnelCode }}</small>
</span>
</template>
</el-table-column>
<el-table-column label="所属现场" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">
<span class="mp-name-cell__text">
<strong>{{ scope.row.siteName || `现场 #${scope.row.siteId}` }}</strong>
<small>{{ scope.row.customerName || '—' }}<template v-if="scope.row.siteCode"> · <span class="mp-mono">{{ scope.row.siteCode }}</span></template></small>
</span>
</template>
</el-table-column>
<el-table-column label="当前物料" min-width="210">
<template #default="scope">
<span class="tunnel-trace">
<span class="mp-pill" :class="scope.row.currentTraceNo ? 'is-site' : ''">{{ scope.row.currentTraceNo ? '有物料' : '空闲' }}</span>
<span v-if="scope.row.currentTraceNo" class="tunnel-trace__text">
<span class="mp-mono" :title="scope.row.currentTraceNo">{{ shortTrace(scope.row.currentTraceNo) }}</span>
<small v-if="scope.row.currentTraceStartedAt" class="mp-muted mp-num">{{ parseTime(scope.row.currentTraceStartedAt, '{m}-{d} {h}:{i}') }} 起</small>
</span>
</span>
</template>
</el-table-column>
<el-table-column label="隧道内托盘" width="130" align="right">
<template #default="scope">
<span class="mp-num">{{ scope.row.activePalletCount ?? 0 }} 托<span class="mp-muted"> · {{ formatCount(scope.row.activePackageQty ?? 0) }} 包</span></span>
</template>
</el-table-column>
<el-table-column label="最近使用" width="130">
<template #default="scope">
<span class="mp-name-cell__text">
<strong class="mp-num">{{ scope.row.lastUsedAt ? parseTime(scope.row.lastUsedAt, '{m}-{d} {h}:{i}') : '—' }}</strong>
<small v-if="scope.row.usageCount30d">近 30 天 {{ scope.row.usageCount30d }} 次</small>
</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" label="操作" width="90" align="right" class-name="small-padding fixed-width nowrap-cell">
<template #default="scope">
<el-button
v-if="proxy.$auth.hasPermiAnd(['master:tunnel:query', 'master:tunnel:edit'])"
link type="primary" icon="Edit" :disabled="detailLoading" @click="handleUpdate(scope.row)" v-hasPermi="['master:tunnel: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" />
<test-batch-adoption-dialog ref="adoptionDialogRef" :root-type="'tunnel'" :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="tunnelRef" :model="form" :rules="rules" label-position="top" class="sf-form">
<el-form-item label="所属现场" prop="siteId">
<master-ref-select v-model="form.siteId" kind="现场" :options="siteOptions" :loading="siteOptionLoading" :failed="siteOptionFailed" :current-label="formSiteName" placeholder="搜索现场名称或编码" />
<div class="sf-form-hint">只列出状态正常且所属客户正常的现场</div>
</el-form-item>
<div class="sf-form-grid">
<el-form-item label="隧道编号" prop="tunnelCode">
<el-input v-model="form.tunnelCode" class="sf-form-mono" placeholder="如 T-03" maxlength="64" />
<div class="sf-form-hint">同一现场下唯一</div>
</el-form-item>
<el-form-item label="隧道名称" prop="tunnelName">
<el-input v-model="form.tunnelName" 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-item v-if="form.tunnelId" label="当前物料溯源号">
<div class="sf-form-fixed"><span class="sf-form-fixed__value">{{ formMeta?.currentTraceNo || '暂无物料' }}</span></div>
<div class="sf-form-hint">由入隧登记自动维护,这里不能修改</div>
</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.tunnelId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:tunnel:add']">创建隧道</el-button>
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:tunnel: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); }
.master-table :deep(.is-muted-row .cell) { opacity:.62; }
.tunnel-trace { display:inline-flex; align-items:center; gap:10px; min-width:0; }
.tunnel-trace__text { display:flex; flex-direction:column; min-width:0; line-height:1.35; }
.tunnel-trace__text small { font-size:12px; }
.master-filter-chip { display:inline-flex; align-items:center; gap:6px; height:28px; margin:0 0 10px; padding:0 6px 0 10px; border-radius:7px; background:var(--sf-accent-soft, #e6f3f5); color:var(--sf-accent-strong, #006a78); font-size:12.5px; }
.master-filter-chip button { width:20px; height:20px; border:0; border-radius:5px; background:transparent; color:inherit; font-size:15px; line-height:1; cursor:pointer; }
</style>
<script setup name="Tunnel">
import { addTunnel, getTunnel, getTunnelSiteOptions, listTunnel, updateTunnel } from '@/api/master/tunnel'
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
import MasterRefSelect from '@/components/MasterRefSelect'
import useUserStore from '@/store/modules/user'
import useTestBatchStore from '@/store/modules/testBatch'
import { formatCount } from '@/utils/masterDisplay'
import { positiveId, useMasterRouteIntent } from '@/utils/masterRouteIntent'
const { proxy } = getCurrentInstance()
const userStore = useUserStore()
const testBatchStore = useTestBatchStore()
const { sys_normal_disable } = proxy.useDict('sys_normal_disable')
const tunnelList = ref([])
const showSearch = ref(true)
const selectedIds = ref([])
const single = ref(true)
const total = ref(0)
const loading = ref(false)
const detailLoading = ref(false)
const submitLoading = ref(false)
const loadError = ref('')
const formError = ref('')
const open = ref(false)
const title = ref('')
const adoptionDialogRef = ref()
const isMasterWriteAllowed = computed(() => userStore.admin === true || !testBatchStore.isTestMode)
const routeFilter = ref({ siteName: '' })
const routeSiteNames = 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(' · ')
: '带 * 的为必填项')
function validateRequiredText(rule, value, callback) {
const labels = { tunnelCode: '隧道编号', tunnelName: '隧道名称' }
if (typeof value !== 'string' || value.trim() === '') {
callback(new Error(`${labels[rule.field]}不能为空`))
return
}
callback()
}
function validatePositiveInteger(rule, value, callback) {
if (!Number.isInteger(value) || value <= 0) {
callback(new Error('现场ID必须为正整数'))
return
}
callback()
}
const data = reactive({
form: {},
queryParams: { pageNum: 1, pageSize: 10, siteId: undefined, tunnelCode: undefined, tunnelName: undefined, status: undefined },
rules: {
siteId: [{ required: true, validator: validatePositiveInteger, trigger: 'change' }],
tunnelCode: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
tunnelName: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
})
const { form, queryParams, rules } = toRefs(data)
const pageTotals = computed(() => tunnelList.value.reduce((totals, row) => {
if (row.currentTraceNo) totals.loaded += 1
else totals.idle += 1
totals.pallets += row.activePalletCount || 0
totals.packages += row.activePackageQty || 0
totals.usages += row.usageCount30d || 0
return totals
}, { loaded: 0, idle: 0, pallets: 0, packages: 0, usages: 0 }))
const formSiteName = computed(() => {
const names = { ...routeSiteNames.value }
tunnelList.value.forEach((row) => {
if (row.siteName) names[row.siteId] = [row.customerName, row.siteName].filter(Boolean).join(' · ')
})
return names[form.value.siteId] || ''
})
function isCurrentListRequest(listContext) {
return listContext.generation === listGeneration
}
function getList() {
const listContext = {
generation: ++listGeneration,
query: { ...queryParams.value }
}
loading.value = true
loadError.value = ''
listTunnel(listContext.query).then((response) => {
if (!isCurrentListRequest(listContext)) {
return
}
tunnelList.value = response.rows
total.value = response.total
}).catch(() => {
if (!isCurrentListRequest(listContext)) {
return
}
tunnelList.value = []
total.value = 0
loadError.value = '隧道列表加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentListRequest(listContext)) {
return
}
loading.value = false
})
}
function reset() {
formError.value = ''
formMeta.value = null
form.value = { tunnelId: undefined, siteId: undefined, tunnelCode: '', tunnelName: '', status: '0', remark: '' }
proxy.resetForm('tunnelRef')
}
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'); routeFilter.value = { siteName: '' }; handleQuery() }
function handleSelectionChange(selection) {
selectedIds.value = selection.map((item) => item.tunnelId)
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 tunnelId = row.tunnelId || selectedIds.value[0]
if (!tunnelId) return
const detailContext = {
generation: ++detailGeneration,
resourceId: tunnelId
}
detailLoading.value = true
getTunnel(detailContext.resourceId).then((response) => {
if (!isCurrentDetailRequest(detailContext)) {
return
}
const detail = response.data
formMeta.value = { currentTraceNo: detail.currentTraceNo, by: detail.updateBy || detail.createBy, time: detail.updateTime || detail.createTime }
form.value = { tunnelId: detail.tunnelId, siteId: detail.siteId, tunnelCode: detail.tunnelCode, tunnelName: detail.tunnelName, 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
reset()
getList()
}
function submitCreate() {
if (submitLoading.value) return
proxy.$refs.tunnelRef.validate((valid) => {
if (!valid) return
submitLoading.value = true
formError.value = ''
addTunnel({
siteId: form.value.siteId,
tunnelCode: form.value.tunnelCode.trim(),
tunnelName: form.value.tunnelName.trim(),
status: form.value.status,
remark: form.value.remark?.trim() || undefined
}).then(afterSubmit).catch(() => {
formError.value = '保存失败,请检查现场和隧道编号后重试'
}).finally(() => { submitLoading.value = false })
})
}
function submitUpdate() {
if (submitLoading.value) return
proxy.$refs.tunnelRef.validate((valid) => {
if (!valid) return
submitLoading.value = true
formError.value = ''
updateTunnel({
tunnelId: form.value.tunnelId,
siteId: form.value.siteId,
tunnelCode: form.value.tunnelCode.trim(),
tunnelName: form.value.tunnelName.trim(),
status: form.value.status,
remark: form.value.remark?.trim() || undefined
}).then(afterSubmit).catch(() => {
formError.value = '保存失败,请检查现场和隧道编号后重试'
}).finally(() => { submitLoading.value = false })
})
}
function tunnelRowClass({ row }) {
return row.status === '1' ? 'is-muted-row' : ''
}
function shortTrace(traceNo) {
const text = String(traceNo || '')
return text.length > 18 ? `${text.slice(0, 8)}…${text.slice(-6)}` : text
}
function clearRouteFilter() {
queryParams.value.siteId = undefined
routeFilter.value = { siteName: '' }
handleQuery()
}
// 从客户详情跳转:按现场筛选,可直接打开新增并预填现场
// 表单里的上级现场选项:每次打开弹窗时刷新,加载失败时退回为直接输入 ID
const siteOptions = ref([])
const siteOptionLoading = ref(false)
const siteOptionFailed = ref(false)
let siteOptionGeneration = 0
function loadSiteOptions() {
const generation = ++siteOptionGeneration
siteOptionLoading.value = true
siteOptionFailed.value = false
getTunnelSiteOptions().then((response) => {
if (generation !== siteOptionGeneration) return
siteOptions.value = Array.isArray(response.data) ? response.data : []
}).catch(() => {
if (generation !== siteOptionGeneration) return
siteOptions.value = []
siteOptionFailed.value = true
}).finally(() => {
if (generation === siteOptionGeneration) siteOptionLoading.value = false
})
}
watch(open, (visible) => {
if (visible) loadSiteOptions()
})
const consumeRouteIntent = useMasterRouteIntent((intent, { initial }) => {
const siteId = positiveId(intent.siteId)
const changed = Boolean(siteId) && siteId !== queryParams.value.siteId
if (siteId) {
queryParams.value.siteId = siteId
routeFilter.value = { siteName: String(intent.siteName || '') }
if (intent.siteName) routeSiteNames.value = { ...routeSiteNames.value, [siteId]: String(intent.siteName) }
}
if (intent.action === 'add' && isMasterWriteAllowed.value && proxy.$auth.hasPermiAnd(['master:tunnel:add'])) {
handleAdd()
form.value.siteId = siteId
}
if (!initial && changed) handleQuery()
})
consumeRouteIntent({ initial: true })
getList()
</script>