更新登录页及优化相关功能

This commit is contained in:
焦龙言
2026-10-08 14:13:05 +08:00
parent 9a14b692e3
commit 56e646bcc1
139 changed files with 77587 additions and 811 deletions
@@ -20,7 +20,7 @@ export function getFillingStrainOptions() {
// 扫描待装填托盘
export function scanFillingPallet(qrToken) {
return request({
url: '/business/filling/scan/' + qrToken,
url: '/business/filling/scan/' + encodeURIComponent(qrToken),
method: 'get'
})
}
@@ -20,7 +20,7 @@ export function getInboundWarehouseOptions() {
// 扫描首次入库托盘
export function scanInboundPallet(qrToken, query) {
return request({
url: '/business/inbound/scan/' + qrToken,
url: '/business/inbound/scan/' + encodeURIComponent(qrToken),
method: 'get',
params: query
})
@@ -20,7 +20,7 @@ export function getReturnWarehouseOptions() {
// 扫描退回托盘
export function scanReturnPallet(qrToken, query) {
return request({
url: '/business/return/scan/' + qrToken,
url: '/business/return/scan/' + encodeURIComponent(qrToken),
method: 'get',
params: query
})
@@ -28,7 +28,7 @@ export function getShipmentSiteOptions() {
// 扫描整托发货托盘
export function scanShipmentPallet(qrToken, query) {
return request({
url: '/business/shipment/scan/' + qrToken,
url: '/business/shipment/scan/' + encodeURIComponent(qrToken),
method: 'get',
params: query
})
@@ -42,6 +42,14 @@ export function getShipment(shipmentId) {
})
}
// 获取发货标签(包含已授权生成的固定二维码)
export function getShipmentLabels(shipmentId) {
return request({
url: '/business/shipment/' + shipmentId + '/labels',
method: 'get'
})
}
// 新增整托发货草稿
export function addShipment(data) {
return request({
@@ -0,0 +1,22 @@
import request from '@/utils/request'
// 收货仅依赖目标现场范围,使用独立端点,不能复用出库查询。
export function listShipmentReceipts(query) {
return request({ url: '/business/shipment-receipt/list', method: 'get', params: query })
}
export function getShipmentReceipt(shipmentId) {
return request({ url: '/business/shipment-receipt/' + shipmentId, method: 'get' })
}
export function scanShipmentReceipt(shipmentId, identifier) {
return request({
url: '/business/shipment-receipt/' + shipmentId + '/scan/' + encodeURIComponent(identifier),
method: 'get'
})
}
export function confirmShipmentReceipt(shipmentId, data) {
return request({ url: '/business/shipment-receipt/' + shipmentId + '/confirm', method: 'put', data })
}
@@ -18,14 +18,41 @@ export function getUsageLineOptions(siteId) {
})
}
// 查询客户现场启用隧道选项
export function getUsageTunnelOptions(siteId) {
return request({
url: '/business/usage/tunnel-options',
method: 'get',
params: { siteId }
})
}
// 扫码入隧
export function enterUsageTunnel(data) {
return request({
url: '/business/usage/enter-tunnel',
method: 'post',
data: data
})
}
// 扫描客户现场待使用托盘
export function scanUsagePallet(qrToken) {
return request({
url: '/business/usage/scan/' + qrToken,
url: '/business/usage/scan/' + encodeURIComponent(qrToken),
method: 'get'
})
}
// 只读先到先用建议,不作为入隧或使用的前置条件
export function getUsageFifoRecommendation(qrToken, tunnelId) {
return request({
url: '/business/usage-fifo/recommendation',
method: 'get',
params: { qrToken, ...(tunnelId != null ? { tunnelId } : {}) }
})
}
// 查询客户现场使用记录详情
export function getUsage(usageId) {
return request({
@@ -0,0 +1,36 @@
import request from '@/utils/request'
// 查询隧道列表
export function listTunnel(query) {
return request({
url: '/master/tunnel/list',
method: 'get',
params: query
})
}
// 查询隧道详情
export function getTunnel(tunnelId) {
return request({
url: '/master/tunnel/' + tunnelId,
method: 'get'
})
}
// 新增隧道
export function addTunnel(data) {
return request({
url: '/master/tunnel',
method: 'post',
data: data
})
}
// 修改隧道
export function updateTunnel(data) {
return request({
url: '/master/tunnel',
method: 'put',
data: data
})
}
@@ -16,7 +16,7 @@
:readonly="loading"
:aria-busy="loading"
:aria-invalid="Boolean(errorMessage)"
placeholder="请扫描或输入托盘二维码后按 Enter"
placeholder="扫描二维码或输入托盘号后按 Enter"
@input="clearError"
@keyup.enter="handleSubmit"
>
@@ -47,7 +47,7 @@ const props = defineProps({
},
minLength: {
type: Number,
default: 8,
default: 1,
validator: (value) => Number.isInteger(value) && value > 0
},
duplicateWindowMs: {
@@ -88,7 +88,7 @@ function handleSubmit() {
try {
normalizedValue = normalizeScanValue(scanValue.value)
if (normalizedValue.length < props.minLength) {
throw new Error(`二维码长度不能少于 ${props.minLength} 个字符`)
throw new Error(`托盘标识长度不能少于 ${props.minLength} 个字符`)
}
} catch (error) {
errorMessage.value = error.message
@@ -0,0 +1,303 @@
<template>
<div v-if="canReceive" class="shipment-receipt-panel">
<el-form :model="queryParams" :inline="true" class="page-filter">
<el-form-item label="收货状态">
<el-select v-model="queryParams.receiptState" style="width: 190px" @change="handleQuery">
<el-option label="全部" value="" />
<el-option label="待收货" value="PENDING" />
<el-option label="已收货" value="RECEIVED" />
<el-option label="历史未登记收货" value="LEGACY" />
</el-select>
</el-form-item>
<el-form-item label="发货单号">
<el-input v-model="queryParams.shipmentNo" clearable maxlength="64" placeholder="请输入原发货单号" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" :loading="loading" @click="handleQuery">查询收货</el-button>
</el-form-item>
</el-form>
<el-alert v-if="listError" :title="listError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" :data="receiptList" empty-text="暂无收货单" class="data-table">
<el-table-column label="原发货单号" prop="shipmentNo" min-width="170" />
<el-table-column label="目标现场ID" prop="targetSiteId" width="130" />
<el-table-column label="来源仓库ID" prop="sourceWarehouseId" width="130" />
<el-table-column label="收货状态" min-width="170">
<template #default="{ row }">{{ receiptStateLabel(row.receiptState) }}</template>
</el-table-column>
<el-table-column label="收货时间" min-width="180">
<template #default="{ row }">{{ row.receiptState === 'RECEIVED' ? (row.receivedAt || '未登记') : '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="130">
<template #default="{ row }">
<el-button link type="primary" @click="openReceipt(row)">{{ row.receiptState === 'PENDING' ? '核对收货' : '查看明细' }}</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="loadReceipts" />
<el-dialog :model-value="activeShipmentId !== null" title="收货核对" width="min(980px, 95vw)" append-to-body @update:model-value="value => { if (!value) closeReceipt() }">
<div v-loading="detailLoading">
<el-alert v-if="detailError" :title="detailError" type="error" :closable="false" show-icon class="mb8" />
<el-button v-if="detailError" :disabled="detailLoading || confirmLoading" @click="retryDetail">重新读取详情</el-button>
<template v-if="receiptDetail">
<el-descriptions :column="2" border class="mb8">
<el-descriptions-item label="原发货单号">{{ receiptDetail.shipmentNo }}</el-descriptions-item>
<el-descriptions-item label="收货状态">{{ receiptStateLabel(receiptDetail.receiptState) }}</el-descriptions-item>
<el-descriptions-item label="目标现场">#{{ receiptDetail.targetSiteId }}</el-descriptions-item>
<el-descriptions-item label="收货时间">{{ receiptDetail.receiptState === 'RECEIVED' ? (receiptDetail.receivedAt || '未登记') : '—' }}</el-descriptions-item>
</el-descriptions>
<el-alert v-if="receiptDetail.receiptState === 'LEGACY'" title="历史未登记收货:仅供查询原单明细,不补造收货时间。" type="info" :closable="false" class="mb8" />
<template v-if="receiptDetail.receiptState === 'PENDING'">
<pallet-scan-input :key="activeShipmentId" :disabled="!canScan" :loading="scanLoading" @scan="handleReceiptScan" @invalid="message => scanError = message" @duplicate="scanError = '扫码过快,请勿重复扫描'" />
<p class="receipt-tip">请逐托核对原标签,已核对 {{ scannedPalletIds.length }} / {{ receiptItems.length }} 托;全部核对后才可确认收货。</p>
</template>
<el-alert v-if="scanError" :title="scanError" type="warning" :closable="false" class="mb8" />
<el-table :data="receiptItems" empty-text="原单无托盘明细,无法确认收货" size="small" max-height="360">
<el-table-column label="原托盘号" prop="palletCode" min-width="150" />
<el-table-column label="品种" prop="strainName" min-width="120" />
<el-table-column label="批号" prop="batchNo" min-width="130" />
<el-table-column label="生产日期" min-width="120">
<template #default="{ row }">{{ row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="失效日期" min-width="120">
<template #default="{ row }">{{ row.expiryDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="原发货包数" prop="packageQty" width="110" />
<el-table-column v-if="receiptDetail.receiptState === 'PENDING'" label="本次核对" width="150">
<template #default="{ row }">
<span>{{ scannedPalletIds.includes(row.palletId) ? '已核对' : '待扫描' }}</span>
<el-button v-if="scannedPalletIds.includes(row.palletId)" link type="danger" :disabled="!canScan || scanLoading" @click="removeScannedPallet(row.palletId)">撤销</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div>
<el-alert v-if="confirmError" :title="confirmError" type="error" :closable="false" class="mb8" />
<template #footer>
<el-button @click="closeReceipt">关闭</el-button>
<el-button v-if="receiptDetail?.receiptState === 'PENDING'" type="primary" :loading="confirmLoading" :disabled="!canConfirmReceipt" @click="confirmReceipt">确认收货</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref } from 'vue'
import PalletScanInput from '@/components/PalletScanInput'
import { listShipmentReceipts, getShipmentReceipt, scanShipmentReceipt, confirmShipmentReceipt } from '@/api/business/shipmentReceipt'
import { createIdempotencyKey } from '@/utils/palletScan'
const { proxy } = getCurrentInstance()
const emit = defineEmits(['received'])
const canReceive = computed(() => proxy.$auth.hasPermi('business:shipment:receive'))
const queryParams = reactive({ pageNum: 1, pageSize: 10, receiptState: 'PENDING', shipmentNo: undefined })
const receiptList = ref([])
const total = ref(0)
const loading = ref(false)
const listError = ref('')
const activeShipmentId = ref(null)
const receiptDetail = ref(null)
const detailLoading = ref(false)
const detailError = ref('')
const scannedPalletIds = ref([])
const scanLoading = ref(false)
const scanError = ref('')
const confirmLoading = ref(false)
const confirmError = ref('')
const receiptAccepted = ref(false)
let session = 0
let listGeneration = 0
let suspended = false
let idempotencyKey = ''
const receiptItems = computed(() => receiptDetail.value?.items || [])
const canScan = computed(() => canReceive.value && activeShipmentId.value !== null &&
receiptDetail.value?.status === 'CONFIRMED' && receiptDetail.value?.receiptState === 'PENDING' &&
!detailLoading.value && !detailError.value && !confirmLoading.value && !receiptAccepted.value)
const canConfirmReceipt = computed(() => canScan.value && !scanLoading.value &&
receiptItems.value.length > 0 && receiptItems.value.length === scannedPalletIds.value.length &&
receiptItems.value.every(item => scannedPalletIds.value.includes(item.palletId)))
function receiptStateLabel(state) {
return { PENDING: '待收货', RECEIVED: '已收货', LEGACY: '历史未登记收货' }[state] || '未登记收货'
}
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback
}
function isCurrent(currentSession) {
return currentSession === session && !suspended && canReceive.value
}
async function loadReceipts() {
if (!canReceive.value || suspended) return
const generation = ++listGeneration
loading.value = true
listError.value = ''
try {
const response = await listShipmentReceipts({ ...queryParams, receiptState: queryParams.receiptState || undefined })
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = Array.isArray(response.rows) ? response.rows : []
total.value = response.total || 0
} catch (error) {
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = []
total.value = 0
listError.value = errorMessage(error, '收货列表加载失败,请重试')
} finally {
if (generation === listGeneration) loading.value = false
}
}
function handleQuery() {
queryParams.pageNum = 1
return loadReceipts()
}
function closeReceipt() {
// 会话版本同时隔离详情、扫码、确认响应,旧请求不能写入新单据。
session += 1
activeShipmentId.value = null
receiptDetail.value = null
scannedPalletIds.value = []
detailLoading.value = false
scanLoading.value = false
confirmLoading.value = false
receiptAccepted.value = false
detailError.value = ''
scanError.value = ''
confirmError.value = ''
idempotencyKey = ''
}
async function loadReceiptDetail(shipmentId, currentSession) {
detailLoading.value = true
detailError.value = ''
try {
const response = await getShipmentReceipt(shipmentId)
if (!isCurrent(currentSession)) return
if (!response.data || String(response.data.shipmentId) !== String(shipmentId)) {
throw new Error('收货详情与当前单据不一致,请重新打开')
}
receiptDetail.value = response.data
} catch (error) {
if (!isCurrent(currentSession)) return
detailError.value = errorMessage(error, '收货详情加载失败,请重试')
} finally {
if (isCurrent(currentSession)) detailLoading.value = false
}
}
async function openReceipt(row) {
if (!canReceive.value || suspended || row.shipmentId == null) return
closeReceipt()
activeShipmentId.value = row.shipmentId
await loadReceiptDetail(row.shipmentId, session)
}
function retryDetail() {
if (activeShipmentId.value === null || detailLoading.value || confirmLoading.value) return
return loadReceiptDetail(activeShipmentId.value, session)
}
async function handleReceiptScan(identifier) {
if (!canScan.value || scanLoading.value) return
const value = String(identifier ?? '').trim()
if (!value) {
scanError.value = '请扫描二维码或输入托盘号'
return
}
const currentSession = session
scanLoading.value = true
scanError.value = ''
try {
const response = await scanShipmentReceipt(activeShipmentId.value, value)
if (!isCurrent(currentSession)) return
const scanned = response.data
const original = receiptItems.value.find(item => String(item.palletId) === String(scanned?.palletId))
if (!original || String(original.activeFillingId) !== String(scanned?.activeFillingId)) {
scanError.value = '该托盘或装填记录不属于当前发货单'
return
}
if (scannedPalletIds.value.includes(original.palletId)) {
scanError.value = '该托盘已核对,请勿重复扫描'
return
}
// 仅登记实际扫码通过的 ID;品种、批号、日期与包数始终展示原单快照。
scannedPalletIds.value.push(original.palletId)
idempotencyKey = ''
confirmError.value = ''
} catch (error) {
if (isCurrent(currentSession)) scanError.value = errorMessage(error, '托盘核对失败,请重试')
} finally {
if (isCurrent(currentSession)) scanLoading.value = false
}
}
function removeScannedPallet(palletId) {
if (!canScan.value || scanLoading.value) return
if (!scannedPalletIds.value.includes(palletId)) return
scannedPalletIds.value = scannedPalletIds.value.filter(id => id !== palletId)
idempotencyKey = ''
confirmError.value = ''
}
async function confirmReceipt() {
if (!canReceive.value || confirmLoading.value || receiptAccepted.value) return
if (!canConfirmReceipt.value) {
confirmError.value = '请逐托扫码核对全部明细后再确认收货'
return
}
const currentSession = session
const shipmentId = activeShipmentId.value
confirmLoading.value = true
confirmError.value = ''
try {
// 失败保留同一键;增删核对集合时作废,下一次提交生成新键。
if (!idempotencyKey) idempotencyKey = createIdempotencyKey()
const response = await confirmShipmentReceipt(shipmentId, {
scannedPalletIds: [...scannedPalletIds.value], idempotencyKey
})
if (!isCurrent(currentSession)) return
receiptAccepted.value = true
proxy.$modal.msgSuccess(response.msg || '收货成功')
emit('received')
// 不推算余量,也不伪造已收状态/时间。刷新失败仍锁定本次已接受的确认。
await Promise.all([loadReceiptDetail(shipmentId, currentSession), loadReceipts()])
} catch (error) {
if (isCurrent(currentSession)) {
confirmError.value = errorMessage(error, '收货确认失败') + ';未修改核对集合时重试使用同一幂等键'
}
} finally {
if (isCurrent(currentSession)) confirmLoading.value = false
}
}
function suspendPanel() {
suspended = true
listGeneration += 1
loading.value = false
closeReceipt()
}
function resumePanel() {
if (!suspended) return
suspended = false
loadReceipts()
}
onMounted(loadReceipts)
onBeforeUnmount(suspendPanel)
onActivated(resumePanel)
onDeactivated(suspendPanel)
</script>
<style scoped>
.receipt-tip {
margin: 10px 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
</style>
+1 -1
View File
@@ -11,7 +11,7 @@ import usePermissionStore from '@/store/modules/permission'
NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/register']
const whiteList = ['/login', '/register', '/open-source-notice']
const isWhiteList = (path) => {
return whiteList.some(pattern => isPathMatch(pattern, path))
@@ -47,6 +47,12 @@ export const constantRoutes = [
component: () => import('@/views/register'),
hidden: true
},
{
path: '/open-source-notice',
component: () => import('@/views/openSourceNotice/index'),
hidden: true,
meta: { title: '开源协议' }
},
{
path: "/:pathMatch(.*)*",
component: () => import('@/views/error/404'),
+1 -1
View File
@@ -52,5 +52,5 @@ export default {
/**
* 底部版权文本内容
*/
footerContent: 'Copyright © 2024-2026 insistence.tech All Rights Reserved.'
footerContent: 'Copyright © 2024-2026 Shierfeng International Biotechnology Co., Ltd. All Rights Reserved.'
}
@@ -149,7 +149,9 @@ service.interceptors.response.use(async res => {
return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
} else if (code === 500) {
ElMessage({ message: msg, type: 'error' })
return Promise.reject(new Error(msg))
const error = new Error(msg)
error.response = res
return Promise.reject(error)
} else if (code === 601) {
ElMessage({ message: msg, type: 'warning' })
return Promise.reject(new Error(msg))
@@ -0,0 +1,103 @@
// 将窗口生命周期和 DOM 排版集中在这里,便于独立验证打印与不可信文本的行为。
function appendText(document, parent, tag, text) {
const element = document.createElement(tag)
element.textContent = String(text ?? '未登记')
parent.appendChild(element)
return element
}
function waitForImage(image) {
return new Promise((resolve, reject) => {
const finish = (error) => {
clearTimeout(timer)
image.removeEventListener('load', loaded)
image.removeEventListener('error', failed)
if (error) reject(error)
else resolve()
}
const loaded = () => finish()
const failed = () => finish(new Error('二维码加载失败,请重新打印'))
const timer = setTimeout(() => finish(new Error('二维码加载超时,请重新打印')), 15000)
image.addEventListener('load', loaded, { once: true })
image.addEventListener('error', failed, { once: true })
if (image.complete) {
if (image.naturalWidth > 0) loaded()
else failed()
}
})
}
export async function printShipmentLabels({
loadLabels,
notifyError,
isCurrent = () => true,
openWindow = () => window.open('', '_blank')
}) {
let printWindow
try {
// 必须在第一次 await 前打开窗口,保留点击手势,避免异步请求触发弹窗拦截。
printWindow = openWindow()
if (!printWindow) throw new Error('打印窗口被拦截,请允许此站点弹出窗口后重试')
const document = printWindow.document
document.title = '发货标签'
document.body.textContent = '正在加载发货标签…'
const labels = await loadLabels()
if (!isCurrent()) throw new Error('数据范围已切换,请重新打印标签')
if (printWindow.closed) throw new Error('打印窗口已关闭,请重新打印')
if (!Array.isArray(labels) || !labels.length) throw new Error('发货单没有可打印的标签')
if (labels.some(label => !/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(label.qrCodeDataUrl))) {
throw new Error('二维码数据无效,请重新加载标签')
}
document.body.textContent = ''
const style = document.createElement('style')
style.textContent = `
@page { margin: 12mm; }
body { margin: 0; color: #000; font-family: sans-serif; }
section { box-sizing: border-box; padding: 8mm; border: 1px solid #000; }
h1 { margin: 0 0 6mm; font-size: 22pt; }
dl { margin: 0; display: grid; grid-template-columns: 30mm minmax(0, 1fr); gap: 3mm; font-size: 13pt; }
dt { font-weight: 600; }
dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
img { display: block; width: 38mm; height: 38mm; margin: 6mm auto 0; }
`
document.head.appendChild(style)
const images = []
labels.forEach((label, index) => {
const page = document.createElement('section')
page.style.breakInside = 'avoid'
page.style.pageBreakInside = 'avoid'
page.style.breakAfter = index === labels.length - 1 ? 'auto' : 'page'
page.style.pageBreakAfter = index === labels.length - 1 ? 'auto' : 'always'
appendText(document, page, 'h1', label.shipmentStatus === 'DRAFT' ? '发货标签(草稿)' : '发货标签')
const fields = document.createElement('dl')
for (const [name, value] of [
['原托盘编号', label.palletCode], ['发货单号', label.shipmentNo],
['客户', label.customerName], ['现场', label.siteName],
['品种', label.strainName], ['原生产批号', label.batchNo],
['生产日期', label.productionDate], ['有效期至', label.expiryDate], ['包数', label.packageQty]
]) {
appendText(document, fields, 'dt', name)
appendText(document, fields, 'dd', value)
}
page.appendChild(fields)
const image = document.createElement('img')
image.alt = '原托盘固定二维码'
image.src = label.qrCodeDataUrl
page.appendChild(image)
document.body.appendChild(page)
images.push(image)
})
await Promise.all(images.map(waitForImage))
if (!isCurrent()) throw new Error('数据范围已切换,请重新打印标签')
if (printWindow.closed) throw new Error('打印窗口已关闭,请重新打印')
printWindow.addEventListener('afterprint', () => printWindow.close(), { once: true })
printWindow.focus()
printWindow.print()
return true
} catch (error) {
if (printWindow && !printWindow.closed) printWindow.close()
notifyError(error?.message || '发货标签加载失败,请稍后重试')
return false
}
}
@@ -46,6 +46,12 @@
<template #default="scope">{{ scope.row.strainNameSnapshot || scope.row.strainCodeSnapshot || `#${scope.row.strainId}` }}</template>
</el-table-column>
<el-table-column label="生产批次" prop="batchNo" min-width="130" :show-overflow-tooltip="true" />
<el-table-column label="生产日期" width="120">
<template #default="scope">{{ scope.row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="有效期至" width="120">
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="包装规格" prop="packageSpec" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="初始包数" prop="initialPackages" width="100" align="right" />
<el-table-column label="操作时间" prop="operatedAt" width="180">
@@ -123,6 +129,12 @@
:disabled="operationPending || scanLoading"
/>
</el-form-item>
<el-form-item label="生产日期" prop="productionDate">
<el-date-picker v-model="form.productionDate" type="date" value-format="YYYY-MM-DD" placeholder="选择来源生产日期" style="width: 100%" :disabled="operationPending || scanLoading" />
</el-form-item>
<el-form-item label="有效期至" prop="expiryDate">
<el-date-picker v-model="form.expiryDate" type="date" value-format="YYYY-MM-DD" placeholder="选择有效期截止日期" style="width: 100%" :disabled="operationPending || scanLoading" />
</el-form-item>
<el-form-item label="包装规格" prop="packageSpec">
<el-input
v-model="form.packageSpec"
@@ -137,11 +149,12 @@
<el-input-number
v-model="form.initialPackages"
class="business-control"
:min="1"
:min="100"
:max="100"
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="operationPending || scanLoading"
disabled
/>
</el-form-item>
</el-form>
@@ -207,19 +220,21 @@ function validateRequiredText(rule, value, callback) {
}
function validatePositiveInteger(rule, value, callback) {
if (!Number.isInteger(value) || value <= 0) {
callback(new Error('初始包数必须为正整数'))
if (value !== 100) {
callback(new Error('新装填固定为 100 包'))
return
}
callback()
}
const data = reactive({
form: { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', packageSpec: '', initialPackages: undefined },
form: { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', productionDate: '', expiryDate: '', packageSpec: '', initialPackages: 100 },
queryParams: { pageNum: 1, pageSize: 10, fillingNo: undefined, palletCode: undefined, batchNo: undefined },
rules: {
strainId: [{ required: true, message: '请选择菌种', trigger: 'change' }],
batchNo: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
productionDate: [{ required: true, message: '请选择生产日期', trigger: 'change' }],
expiryDate: [{ required: true, message: '请选择有效期截止日期', trigger: 'change' }],
packageSpec: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
initialPackages: [{ required: true, validator: validatePositiveInteger, trigger: 'blur' }]
}
@@ -308,8 +323,10 @@ function clearFillingPayload() {
warehouseId: undefined,
strainId: undefined,
batchNo: '',
productionDate: '',
expiryDate: '',
packageSpec: '',
initialPackages: undefined
initialPackages: 100
}
}
@@ -382,7 +399,7 @@ function handleFillingScan(qrToken) {
flowState: pallet.flowState,
capacityPackages: pallet.capacityPackages
}
form.value.qrToken = qrToken
form.value.qrToken = pallet.qrToken || qrToken
form.value.warehouseId = pallet.warehouseId
}).catch(() => {
if (!isCurrentScanRequest(scanContext)) return
@@ -405,6 +422,10 @@ function handleScanDuplicate() {
function submitFilling() {
if (operationPending.value || scanLoading.value || optionLoading.value) return
if (!form.value.productionDate || !form.value.expiryDate || form.value.expiryDate < form.value.productionDate) {
formError.value = '请填写生产日期和有效期,且有效期不能早于生产日期'
return
}
submitLoading.value = true
proxy.$refs.fillingRef.validate((valid) => {
if (!valid) {
@@ -423,6 +444,8 @@ function submitFilling() {
warehouseId: form.value.warehouseId,
strainId: form.value.strainId,
batchNo: form.value.batchNo.trim(),
productionDate: form.value.productionDate,
expiryDate: form.value.expiryDate,
packageSpec: form.value.packageSpec.trim(),
initialPackages: form.value.initialPackages,
idempotencyKey: idempotencyKey
@@ -441,7 +464,7 @@ function submitFilling() {
}
watch(
() => [form.value.qrToken, form.value.warehouseId, form.value.strainId, form.value.batchNo, form.value.packageSpec, form.value.initialPackages],
() => [form.value.qrToken, form.value.warehouseId, form.value.strainId, form.value.batchNo, form.value.productionDate, form.value.expiryDate, form.value.packageSpec, form.value.initialPackages],
() => {
if (!submitLoading.value) clearFillingIdempotencyKey()
}
@@ -1,5 +1,11 @@
<template>
<div class="app-container business-page business-shipment">
<el-tabs v-if="canReceive" v-model="activeTab">
<el-tab-pane v-if="canShipment" label="发货出库" name="shipment" />
<el-tab-pane label="收货核对" name="receipt" :disabled="operationPending || open" />
</el-tabs>
<ShipmentReceiptPanel v-if="canReceive && activeTab === 'receipt'" @received="getList" />
<template v-if="canShipment && activeTab === 'shipment'">
<el-form ref="queryRef" :model="queryParams" :inline="true" class="page-filter">
<el-form-item label="发货单号" prop="shipmentNo">
<el-input v-model="queryParams.shipmentNo" placeholder="请输入发货单号" clearable maxlength="64" @keyup.enter="handleQuery" />
@@ -50,10 +56,10 @@
<el-table-column label="目标现场" min-width="130">
<template #default="scope">{{ optionLabel(siteOptions, scope.row.targetSiteId) }}</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="110" align="center">
<el-table-column label="状态" prop="status" min-width="170" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'DRAFT' ? 'warning' : 'success'">
{{ scope.row.status === 'DRAFT' ? '草稿' : '已确认' }}
<el-tag :type="scope.row.status === 'DRAFT' || scope.row.receiptState === 'PENDING' ? 'warning' : scope.row.receiptState === 'LEGACY' ? 'info' : 'success'">
{{ shipmentStatusLabel(scope.row) }}
</el-tag>
</template>
</el-table-column>
@@ -80,6 +86,15 @@
@click="handleConfirm(scope.row)"
v-hasPermi="['business:shipment:confirm']"
>确认</el-button>
<el-button
link
type="primary"
icon="Printer"
:loading="labelLoadingId === scope.row.shipmentId"
:disabled="operationPending || labelLoadingId !== null"
@click="handlePrintLabels(scope.row)"
v-hasPermi="['business:shipment:query']"
>打印标签</el-button>
</template>
</el-table-column>
</el-table>
@@ -200,12 +215,15 @@
</div>
</template>
</el-dialog>
</template>
</div>
</template>
<script setup name="BusinessShipment">
import PalletScanInput from '@/components/PalletScanInput'
import { createIdempotencyKey } from '@/utils/palletScan'
import { printShipmentLabels } from '@/utils/shipmentLabelPrint'
import { getStoredTestBatchId } from '@/utils/testBatchContext'
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
import useUserStore from '@/store/modules/user'
import useTestBatchStore from '@/store/modules/testBatch'
@@ -213,6 +231,7 @@ import {
addShipment,
confirmShipment,
getShipment,
getShipmentLabels,
getShipmentSiteOptions,
getShipmentWarehouseOptions,
listShipment,
@@ -223,6 +242,16 @@ import {
const { proxy } = getCurrentInstance()
const userStore = useUserStore()
const testBatchStore = useTestBatchStore()
const ShipmentReceiptPanel = defineAsyncComponent(() => import('@/components/ShipmentReceiptPanel/index.vue'))
const canReceive = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:receive']))
const canListShipment = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:list']))
const canShipment = computed(() =>
canListShipment.value ||
proxy.$auth.hasPermiAnd(['business:shipment:query']) ||
proxy.$auth.hasPermiAnd(['business:shipment:add']) ||
proxy.$auth.hasPermiAnd(['business:shipment:edit']) ||
proxy.$auth.hasPermiAnd(['business:shipment:confirm']))
const activeTab = ref(canShipment.value ? 'shipment' : 'receipt')
const adoptionDialogRef = ref()
const selectedIds = ref([])
const shipmentList = ref([])
@@ -235,6 +264,7 @@ const detailLoading = ref(false)
const scanLoading = ref(false)
const submitLoading = ref(false)
const confirmLoading = ref(false)
const labelLoadingId = ref(null)
const loadError = ref('')
const optionError = ref('')
const scanError = ref('')
@@ -270,11 +300,17 @@ function optionLabel(options, id) {
return option?.name || (id ? `#${id}` : '—')
}
function shipmentStatusLabel(row) {
if (row.status === 'DRAFT') return '草稿'
return { PENDING: '待收货', RECEIVED: '已收货', LEGACY: '历史未登记收货' }[row.receiptState] || '已确认'
}
function isCurrentListRequest(listContext) {
return listContext.generation === listGeneration
}
function getList() {
if (!canListShipment.value) return
const listContext = {
generation: ++listGeneration,
query: { ...queryParams.value }
@@ -305,6 +341,22 @@ function getList() {
function handleSelectionChange(selection) { selectedIds.value = selection.map((item) => item.shipmentId) }
function openAdoptionDialog() { adoptionDialogRef.value?.open() }
async function handlePrintLabels(row) {
if (operationPending.value || labelLoadingId.value !== null) return
const batchId = getStoredTestBatchId()
const scopeVersion = testBatchStore.scopeVersion
labelLoadingId.value = row.shipmentId
try {
await printShipmentLabels({
loadLabels: () => getShipmentLabels(row.shipmentId).then(response => response.data),
isCurrent: () => scopeVersion === testBatchStore.scopeVersion && batchId === getStoredTestBatchId(),
notifyError: message => proxy.$modal.msgError(message)
})
} finally {
labelLoadingId.value = null
}
}
function invalidateOptionRequest() {
optionGeneration += 1
optionLoading.value = false
@@ -1,5 +1,17 @@
<template>
<div class="app-container business-page usage-page">
<el-alert v-if="operationError" :title="operationError" type="error" :closable="false" show-icon class="mb8" />
<el-alert v-if="storageError" :title="storageError" type="error" :closable="false" show-icon class="mb8" />
<el-alert v-if="pendingOperation" type="warning" :closable="false" show-icon class="mb8">
<template #title>有一笔{{ pendingOperation.kind === 'usage' ? '使用' : '入隧' }}结果未确认,请恢复重试核对,勿重复操作。</template>
<div>
托盘 {{ pendingOperation.pallet.palletCode }},隧道 {{ formatTunnel(pendingOperation.pallet) }}
<span v-if="pendingOperation.kind === 'usage'">,本次使用 {{ pendingOperation.payload.usedPackages }} 包</span>
</div>
<div>持续失败时,请先查询使用记录及库存,确认原操作结果后再解除本机待确认。重复提交可能再次扣减库存。</div>
<el-button v-if="canAddUsage" type="warning" :disabled="operationPending || Boolean(storageError)" :loading="entryLoading || submitLoading" @click="retryPendingOperation" v-hasPermi="['business:usage:add']">恢复重试</el-button>
<el-button :disabled="operationPending || Boolean(storageError)" :loading="releaseLoading" @click="releasePendingOperation">人工核对后解除待确认</el-button>
</el-alert>
<el-card v-if="canAddUsage" class="usage-operation" shadow="never">
<template #header>
<div class="usage-operation__header">
@@ -8,7 +20,15 @@
</div>
</template>
<el-alert v-if="operationError" :title="operationError" type="error" :closable="false" show-icon class="mb8" />
<el-alert v-if="lineWarning" :title="lineWarning" type="warning" :closable="false" show-icon class="mb8" />
<el-alert v-if="fifoWarning" :title="fifoWarning" type="warning" :closable="false" show-icon class="mb8" />
<el-alert v-else-if="fifoRecommendation" :title="fifoRecommendation.message" type="info" :closable="false" show-icon class="mb8">
<div v-if="fifoRecommendation.recommended">
建议优先使用:{{ fifoRecommendation.recommended.palletCode }},批次 {{ fifoRecommendation.recommended.batchNo }},
余量 {{ fifoRecommendation.recommended.remainingPackages }} 包,接收时间 {{ fifoRecommendation.recommended.siteArrivedAt }}
</div>
<div v-if="fifoRecommendation.unknownArrivalCount">另有 {{ fifoRecommendation.unknownArrivalCount }} 个托盘接收时间未登记,请人工核对。</div>
</el-alert>
<el-alert
v-if="usageResult"
:title="`使用成功:后端返回余量 ${usageResult.remainingPackages} 包,状态 ${usageResult.flowState}`"
@@ -17,12 +37,20 @@
show-icon
class="mb8"
/>
<el-alert
v-if="scannedPallet && !tunnelLocked"
title="请先选择隧道并完成扫码入隧,再提交使用。"
type="warning"
:closable="false"
show-icon
class="mb8"
/>
<div class="usage-grid">
<section class="usage-panel">
<div class="usage-label">扫描托盘</div>
<pallet-scan-input
:disabled="operationPending || optionLoading"
:disabled="operationPending || recoveryRequired || optionLoading"
:loading="scanLoading"
@scan="handleUsageScan"
@invalid="handleScanInvalid"
@@ -36,6 +64,9 @@
<div class="summary-item"><span>托盘</span><strong>{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}</strong></div>
<div class="summary-item"><span>菌种</span><strong>{{ scannedPallet.strainName || scannedPallet.strainCode || (scannedPallet.strainId ? `#${scannedPallet.strainId}` : '—') }}</strong></div>
<div class="summary-item"><span>批次</span><strong>{{ scannedPallet.batchNo || '—' }}</strong></div>
<div class="summary-item"><span>生产日期</span><strong>{{ scannedPallet.productionDate || '未登记' }}</strong></div>
<div class="summary-item"><span>有效期至</span><strong>{{ scannedPallet.expiryDate || '未登记' }}</strong></div>
<div class="summary-item"><span>隧道</span><strong>{{ formatTunnel(scannedPallet) }}</strong></div>
<div class="summary-item"><span>当前余量</span><strong>{{ scannedPallet.remainingPackages }} 包</strong></div>
</template>
<el-empty v-else description="等待扫描托盘" :image-size="72" />
@@ -45,15 +76,32 @@
<el-form ref="usageRef" :model="form" :rules="rules" label-position="top" class="usage-form">
<el-row :gutter="18">
<el-col :xs="24" :sm="12">
<el-form-item label="使用产线" prop="lineId">
<el-form-item label="使用隧道" prop="tunnelId">
<el-select
v-model="form.lineId"
v-model="form.tunnelId"
class="business-control"
placeholder="请先扫描托盘"
filterable
style="width: 100%"
:loading="optionLoading"
:disabled="operationPending || lineLocked || optionLoading || !scannedPallet"
:disabled="operationPending || recoveryRequired || tunnelLocked || optionLoading || !scannedPallet"
>
<el-option v-for="option in tunnelOptions" :key="option.id" :label="formatTunnel({ tunnelId: option.id, tunnelName: option.name, tunnelCode: option.code })" :value="option.id" />
</el-select>
<div v-if="tunnelLocked" class="usage-tip">托盘已完成入隧,当前操作不可切换。</div>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="使用产线(可选)" prop="lineId">
<el-select
v-model="form.lineId"
class="business-control"
placeholder="请先扫描托盘"
filterable
clearable
style="width: 100%"
:loading="optionLoading"
:disabled="operationPending || recoveryRequired || lineLocked || !scannedPallet"
>
<el-option v-for="option in lineOptions" :key="option.id" :label="option.name || `#${option.id}`" :value="option.id" />
</el-select>
@@ -69,24 +117,42 @@
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="operationPending || !scannedPallet"
:disabled="operationPending || recoveryRequired || !scannedPallet"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="跳过先到托盘原因" prop="fifoSkipReason" :required="fifoRecommendation?.requiresSkipReason === true">
<el-input v-model="form.fifoSkipReason" type="textarea" :rows="3" maxlength="500" show-word-limit
placeholder="跳过先到托盘时请填写原因,最多500字"
:disabled="operationPending || recoveryRequired || !scannedPallet" />
<div class="usage-tip">{{ fifoRecommendation?.requiresSkipReason ? '存在更早到货托盘,请填写跳过原因。' : '可选;提交时由服务端复核先到先用要求。' }}</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="usage-actions">
<el-button
v-if="scannedPallet && !tunnelLocked"
class="business-action"
type="warning"
size="large"
:loading="entryLoading"
:disabled="operationPending || recoveryRequired || scanLoading || optionLoading || !scannedPallet || !form.tunnelId"
@click="enterTunnel"
v-hasPermi="['business:usage:add']"
>扫码入隧</el-button>
<el-button
class="business-action"
type="primary"
size="large"
:loading="submitLoading"
:disabled="operationPending || scanLoading || optionLoading || !scannedPallet || Boolean(usageResult)"
:disabled="operationPending || recoveryRequired || scanLoading || optionLoading || !scannedPallet || !tunnelLocked || Boolean(usageResult)"
@click="submitUsage"
v-hasPermi="['business:usage:add']"
>提交使用</el-button>
<el-button class="business-action" size="large" :disabled="operationPending" @click="resetUsageOperation">清空重扫</el-button>
<el-button class="business-action" size="large" :disabled="operationPending || recoveryRequired" @click="resetUsageOperation">清空重扫</el-button>
</div>
</el-card>
@@ -102,6 +168,9 @@
<el-form-item label="现场ID" prop="siteId">
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="请输入现场ID" />
</el-form-item>
<el-form-item label="隧道ID" prop="tunnelId">
<el-input-number v-model="queryParams.tunnelId" :min="1" :controls="false" placeholder="请输入隧道ID" />
</el-form-item>
<el-form-item label="产线ID" prop="lineId">
<el-input-number v-model="queryParams.lineId" :min="1" :controls="false" placeholder="请输入产线ID" />
</el-form-item>
@@ -125,9 +194,15 @@
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
</el-table-column>
<el-table-column label="现场ID" prop="siteId" width="100" />
<el-table-column label="隧道" min-width="150">
<template #default="scope">{{ formatTunnel(scope.row) }}</template>
</el-table-column>
<el-table-column label="产线ID" prop="lineId" width="100" />
<el-table-column label="使用包数" prop="usedPackages" width="105" align="right" />
<el-table-column label="使用后余量" prop="remainingPackagesAfter" width="115" align="right" />
<el-table-column label="跳过先到托盘原因" prop="fifoSkipReason" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.fifoSkipReason || '—' }}</template>
</el-table-column>
<el-table-column label="发生时间" prop="occurredAt" width="180">
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
</el-table-column>
@@ -150,7 +225,15 @@ import { createIdempotencyKey } from '@/utils/palletScan'
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
import useUserStore from '@/store/modules/user'
import useTestBatchStore from '@/store/modules/testBatch'
import { addUsage, getUsageLineOptions, listUsage, scanUsagePallet } from '@/api/business/usage'
import {
addUsage,
enterUsageTunnel,
getUsageFifoRecommendation,
getUsageLineOptions,
getUsageTunnelOptions,
listUsage,
scanUsagePallet
} from '@/api/business/usage'
const { proxy } = getCurrentInstance()
const userStore = useUserStore()
@@ -159,23 +242,39 @@ const adoptionDialogRef = ref()
const selectedIds = ref([])
const usageList = ref([])
const lineOptions = ref([])
const tunnelOptions = ref([])
const scannedPallet = ref(null)
const usageResult = ref(null)
const lineLocked = ref(false)
const tunnelLocked = ref(false)
const loading = ref(false)
const optionLoading = ref(false)
const scanLoading = ref(false)
const entryLoading = ref(false)
const submitLoading = ref(false)
const releaseLoading = ref(false)
const loadError = ref('')
const operationError = ref('')
const lineWarning = ref('')
const fifoRecommendation = ref(null)
const fifoWarning = ref('')
const pendingOperation = ref(null)
const storageError = ref('')
const total = ref(0)
const usageIdempotencyKey = ref('')
const entryIdempotencyKey = ref('')
const canAddUsage = computed(() => proxy.$auth.hasPermiAnd(['business:usage:query', 'business:usage:add']))
const operationPending = computed(() => submitLoading.value)
const operationPending = computed(() => submitLoading.value || entryLoading.value || releaseLoading.value)
const recoveryRequired = computed(() => Boolean(pendingOperation.value || storageError.value))
const pendingStorageKey = computed(() => userStore.id === '' || userStore.id == null
? ''
: `business:usage:pending:v1:${JSON.stringify([String(userStore.id), String(testBatchStore.currentBatchId || '')])}`)
let listGeneration = 0
let optionGeneration = 0
let scanGeneration = 0
let fifoGeneration = 0
let operationSession = 0
let pageActive = true
function validatePositiveInteger(rule, value, callback) {
if (!Number.isInteger(value) || value <= 0) {
@@ -186,17 +285,25 @@ function validatePositiveInteger(rule, value, callback) {
}
const data = reactive({
form: { qrToken: '', lineId: undefined, usedPackages: undefined },
queryParams: { pageNum: 1, pageSize: 10, usageNo: undefined, palletCode: undefined, siteId: undefined, lineId: undefined },
form: { qrToken: '', tunnelId: undefined, lineId: undefined, usedPackages: undefined, fifoSkipReason: '' },
queryParams: {
pageNum: 1,
pageSize: 10,
usageNo: undefined,
palletCode: undefined,
siteId: undefined,
tunnelId: undefined,
lineId: undefined
},
rules: {
lineId: [{ required: true, message: '请选择使用产线', trigger: 'change' }],
tunnelId: [{ required: true, message: '请选择使用隧道', trigger: 'change' }],
usedPackages: [{ required: true, validator: validatePositiveInteger, trigger: 'blur' }]
}
})
const { form, queryParams, rules } = toRefs(data)
function isCurrentListRequest(listContext) {
return listContext.generation === listGeneration
return pageActive && listContext.generation === listGeneration
}
function getList() {
@@ -236,7 +343,7 @@ function invalidateOptionRequest() {
}
function isCurrentOptionRequest(optionContext) {
return optionContext.generation === optionGeneration &&
return pageActive && optionContext.generation === optionGeneration &&
optionContext.operationSession === operationSession &&
optionContext.scanGeneration === scanGeneration &&
optionContext.palletId === scannedPallet.value?.palletId
@@ -248,10 +355,48 @@ function invalidateScanRequest() {
}
function isCurrentScanRequest(scanContext) {
return scanContext.generation === scanGeneration && scanContext.operationSession === operationSession
return pageActive && scanContext.generation === scanGeneration && scanContext.operationSession === operationSession
}
function loadLineOptions(pallet, scanContext) {
function formatTunnel(pallet) {
return [pallet.tunnelName, pallet.tunnelCode].filter(Boolean).join(' / ') || (pallet.tunnelId ? `#${pallet.tunnelId}` : '未入隧')
}
function loadFifoRecommendation() {
if (!pageActive || !scannedPallet.value || !form.value.qrToken) return
const generation = ++fifoGeneration
const scanContext = { generation: scanGeneration, operationSession }
const storageKey = pendingStorageKey.value
const { qrToken, tunnelId } = form.value
const isCurrent = () => generation === fifoGeneration && isCurrentScanRequest(scanContext) &&
storageKey === pendingStorageKey.value && qrToken === form.value.qrToken && tunnelId === form.value.tunnelId
fifoWarning.value = ''
getUsageFifoRecommendation(qrToken, tunnelId).then((response) => {
if (!isCurrent()) return
if (fifoRecommendation.value?.requiresSkipReason === true && typeof response.data?.requiresSkipReason !== 'boolean') {
throw new Error('先到先用建议缺少明确的原因要求')
}
fifoRecommendation.value = response.data
}).catch(() => {
if (!isCurrent()) return
fifoWarning.value = '先到先用建议获取失败,请人工核对接收时间;提交时由服务端复核,跳过先到托盘可能需要填写原因'
})
}
function validatedFifoSkipReason() {
const reason = form.value.fifoSkipReason.trim()
if (Array.from(reason).length > 500) {
operationError.value = '跳过先到托盘原因最多500字'
return null
}
if (fifoRecommendation.value?.requiresSkipReason === true && !reason) {
operationError.value = '请填写跳过先到托盘原因'
return null
}
return reason
}
function loadUsageOptions(pallet, scanContext) {
const optionContext = {
generation: ++optionGeneration,
operationSession,
@@ -260,41 +405,260 @@ function loadLineOptions(pallet, scanContext) {
siteId: pallet.siteId
}
optionLoading.value = true
getUsageLineOptions(pallet.siteId).then((response) => {
const tunnelRequest = getUsageTunnelOptions(pallet.siteId)
const lineRequest = pallet.currentLineId == null
? getUsageLineOptions(pallet.siteId)
: Promise.resolve({ data: [{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `#${pallet.currentLineId}` }] })
// 产线是可选项,不能用 Promise.all 让失败或慢响应影响隧道。
tunnelRequest.then((tunnelResponse) => {
if (!isCurrentOptionRequest(optionContext)) return
lineOptions.value = Array.isArray(response.data) ? response.data : []
if (!lineOptions.value.length) operationError.value = '该现场暂无可用产线'
tunnelOptions.value = Array.isArray(tunnelResponse.data) ? tunnelResponse.data : []
if (pallet.tunnelId != null) {
const tunnel = tunnelOptions.value.find(option => option.id === pallet.tunnelId)
if (tunnel) {
scannedPallet.value.tunnelName = tunnel.name
scannedPallet.value.tunnelCode = tunnel.code
}
}
if (pallet.tunnelId == null && !tunnelOptions.value.length) {
operationError.value = '该现场暂无可用隧道'
}
}).catch(() => {
if (!isCurrentOptionRequest(optionContext)) return
lineOptions.value = []
operationError.value = '产线选项加载失败,请稍后重新扫码'
tunnelOptions.value = []
operationError.value = '隧道选项加载失败,请稍后重新扫码'
}).finally(() => {
if (!isCurrentOptionRequest(optionContext)) return
optionLoading.value = false
})
lineRequest.then((lineResponse) => {
if (!isCurrentOptionRequest(optionContext)) return
lineOptions.value = Array.isArray(lineResponse.data) ? lineResponse.data : []
}).catch(() => {
if (!isCurrentOptionRequest(optionContext)) return
lineOptions.value = []
lineWarning.value = '可选产线加载失败,可不选产线继续操作'
})
}
function clearUsageIdempotencyKey() {
usageIdempotencyKey.value = ''
}
function clearEntryIdempotencyKey() {
entryIdempotencyKey.value = ''
}
function ensureUsageIdempotencyKey() {
if (!usageIdempotencyKey.value) usageIdempotencyKey.value = createIdempotencyKey()
return usageIdempotencyKey.value
}
function ensureEntryIdempotencyKey() {
if (!entryIdempotencyKey.value) entryIdempotencyKey.value = createIdempotencyKey()
return entryIdempotencyKey.value
}
// 请求发送前持久化;未知结果按原 payload/key 核对,或经人工确认后显式释放,不能通过重扫换键。
function restorePendingOperation() {
storageError.value = ''
if (!pendingStorageKey.value) {
storageError.value = '用户信息未就绪,无法读取本机恢复记录,请重新进入页面'
return
}
try {
const raw = localStorage.getItem(pendingStorageKey.value)
if (!raw) return
const record = JSON.parse(raw)
if (!['entry', 'usage'].includes(record.kind) || !record.payload?.idempotencyKey ||
!record.payload.qrToken || !record.payload.tunnelId || !record.pallet?.palletId ||
(record.kind === 'usage' && (!Number.isInteger(record.payload.usedPackages) || record.payload.usedPackages <= 0))) {
throw new Error('恢复记录不完整')
}
pendingOperation.value = record
} catch {
storageError.value = '本机存储不可用或恢复记录损坏,请保留记录并人工核对后处理,暂不能发起新操作'
}
}
function requiresRecovery() {
restorePendingOperation()
if (pendingOperation.value) operationError.value = '上一笔结果未确认,请先使用“恢复重试”核对原操作'
return recoveryRequired.value
}
function savePendingOperation(kind, payload) {
try {
const tunnel = tunnelOptions.value.find(option => option.id === payload.tunnelId)
const record = JSON.parse(JSON.stringify({ kind, payload, pallet: {
...scannedPallet.value,
tunnelId: payload.tunnelId,
tunnelName: tunnel?.name || scannedPallet.value.tunnelName,
tunnelCode: tunnel?.code || scannedPallet.value.tunnelCode
} }))
localStorage.setItem(pendingStorageKey.value, JSON.stringify(record))
pendingOperation.value = record
return record
} catch {
storageError.value = '本机存储失败,尚未发送请求;请恢复浏览器存储后重试'
return null
}
}
function isCurrentOperationRequest(context) {
return pageActive && context.session === operationSession && context.storageKey === pendingStorageKey.value
}
function handleFifoRejection(error, record, context) {
const body = error?.response?.data
if (body?.code !== 500 || body.data !== 'FIFO_SKIP_REASON_REQUIRED') return false
// 服务端保证此错误未落业务数据;仅解除仍与原请求完全一致的本机凭据。
try {
if (localStorage.getItem(context.storageKey) !== JSON.stringify(record)) {
restorePendingOperation()
operationError.value = '本机待确认记录已变化,请重新核对后操作'
return true
}
localStorage.removeItem(context.storageKey)
} catch {
operationError.value = '本机待确认记录清理失败,原凭据已保留,请使用恢复重试'
return true
}
pendingOperation.value = null
if (record.kind === 'usage') clearUsageIdempotencyKey()
else clearEntryIdempotencyKey()
scannedPallet.value = { ...record.pallet, tunnelId: record.kind === 'usage' ? record.payload.tunnelId : null }
form.value = { qrToken: record.payload.qrToken, tunnelId: record.payload.tunnelId,
lineId: record.payload.lineId ?? form.value.lineId,
usedPackages: record.payload.usedPackages ?? form.value.usedPackages,
fifoSkipReason: record.payload.fifoSkipReason || '' }
tunnelLocked.value = record.kind === 'usage'
lineLocked.value = record.pallet.currentLineId != null
usageResult.value = null
operationError.value = '请补充跳过先到托盘原因后重新提交,本次操作未生效'
fifoRecommendation.value = { requiresSkipReason: true, message: '请填写跳过先到托盘原因' }
loadUsageOptions(scannedPallet.value, { generation: scanGeneration })
loadFifoRecommendation()
return true
}
async function executeOperation(record) {
const context = { session: operationSession, storageKey: pendingStorageKey.value }
const isUsage = record.kind === 'usage'
const requestLoading = isUsage ? submitLoading : entryLoading
requestLoading.value = true
operationError.value = ''
try {
const payload = { ...record.payload }
const response = await (isUsage ? addUsage(payload) : enterUsageTunnel(payload))
if (!isCurrentOperationRequest(context)) return
const result = response.data
if (!result || (isUsage
? !Number.isInteger(result.remainingPackages) || result.remainingPackages < 0 || !result.flowState
: result.tunnelId !== record.payload.tunnelId)) {
throw new Error('服务端确认结果不完整')
}
// 同账号/批次的其他页面可能已保存新操作;成功也只能清理完全匹配的原记录。
if (localStorage.getItem(context.storageKey) !== JSON.stringify(record)) {
pendingOperation.value = null
restorePendingOperation()
operationError.value = '本机待确认记录已变化,已保留并恢复当前记录,请核对后操作'
return
}
// 清理失败时仍保留恢复凭据;再次核对也只重放同一幂等命令。
localStorage.removeItem(context.storageKey)
pendingOperation.value = null
// 恢复入口不依赖重新扫码成功:托盘可能已用尽,扫描接口会拒绝它。
scannedPallet.value = { ...record.pallet, ...(isUsage
? { remainingPackages: result.remainingPackages, flowState: result.flowState }
: { tunnelId: result.tunnelId, traceNo: result.traceNo }) }
form.value = { qrToken: record.payload.qrToken, tunnelId: scannedPallet.value.tunnelId,
lineId: isUsage ? record.payload.lineId ?? undefined : record.pallet.currentLineId ?? form.value.lineId,
usedPackages: isUsage ? record.payload.usedPackages : form.value.usedPackages,
fifoSkipReason: record.payload.fifoSkipReason || '' }
tunnelLocked.value = true
lineLocked.value = record.pallet.currentLineId != null
if (isUsage) {
usageResult.value = { remainingPackages: result.remainingPackages, flowState: result.flowState }
getList()
} else {
loadUsageOptions(scannedPallet.value, { generation: scanGeneration })
}
clearUsageIdempotencyKey()
clearEntryIdempotencyKey()
proxy.$modal.msgSuccess(response.msg || (isUsage ? '使用记录提交成功' : '扫码入隧成功'))
} catch (error) {
if (!isCurrentOperationRequest(context)) return
if (handleFifoRejection(error, record, context)) return
operationError.value = '操作结果未确认,请使用“恢复重试”按原请求核对;持续失败请人工核对,勿重复操作'
} finally {
if (isCurrentOperationRequest(context)) requestLoading.value = false
}
}
function retryPendingOperation() {
if (!pageActive || !canAddUsage.value || operationPending.value) return
restorePendingOperation()
if (!pendingOperation.value || storageError.value) return
return executeOperation(pendingOperation.value)
}
async function releasePendingOperation() {
// 仅清理当前账号/批次的本机记录;新增权限撤销后也允许人工解除,不调用业务写接口。
if (!pageActive || operationPending.value) return
restorePendingOperation()
if (!pendingOperation.value || storageError.value) return
const context = { session: operationSession, storageKey: pendingStorageKey.value }
const record = pendingOperation.value
releaseLoading.value = true
try {
const original = localStorage.getItem(context.storageKey)
const operation = record.kind === 'usage' ? `使用 ${record.payload.usedPackages} 包` : '入隧'
await proxy.$modal.confirm(
`托盘 ${record.pallet.palletCode},${formatTunnel(record.pallet)},${operation}的结果尚未确认。` +
'请先查询当前账号、当前数据批次的使用记录及库存,确认原操作是否已生效。' +
'解除仅删除本机待确认记录,不重放请求、不撤销操作,也不代表原操作成功。' +
'解除后重复提交可能再次扣减库存。确认已完成人工核对并解除待确认?'
)
if (!isCurrentOperationRequest(context)) return
// 确认只针对弹窗打开时的记录,不能删除另一个页面后来保存的待确认操作。
if (localStorage.getItem(context.storageKey) !== original) {
restorePendingOperation()
operationError.value = '本机待确认记录已变化,请重新核对后操作'
return
}
localStorage.removeItem(context.storageKey)
pendingOperation.value = null
releaseLoading.value = false
resetUsageOperation()
proxy.$modal.msgWarning('仅清理了本机待确认记录,未发送业务请求;原操作结果以人工核对为准。请重新扫码,避免重复扣减。')
} catch (error) {
if (!isCurrentOperationRequest(context) || error === 'cancel' || error === 'close') return
operationError.value = '本机待确认记录清理失败,原凭据已保留,请稍后重试'
} finally {
if (isCurrentOperationRequest(context)) releaseLoading.value = false
}
}
function resetUsageOperation() {
if (operationPending.value) return
if (operationPending.value || requiresRecovery()) return
operationSession += 1
invalidateOptionRequest()
invalidateScanRequest()
clearUsageIdempotencyKey()
clearEntryIdempotencyKey()
scannedPallet.value = null
usageResult.value = null
lineOptions.value = []
tunnelOptions.value = []
lineLocked.value = false
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
tunnelLocked.value = false
form.value = { qrToken: '', tunnelId: undefined, lineId: undefined, usedPackages: undefined, fifoSkipReason: '' }
operationError.value = ''
lineWarning.value = ''
fifoRecommendation.value = null
fifoWarning.value = ''
proxy.resetForm('usageRef')
}
@@ -309,17 +673,23 @@ function resetQuery() {
}
function handleUsageScan(qrToken) {
if (operationPending.value || scanLoading.value || optionLoading.value) return
if (!pageActive || !canAddUsage.value || operationPending.value || scanLoading.value || optionLoading.value || requiresRecovery()) return
invalidateOptionRequest()
clearUsageIdempotencyKey()
clearEntryIdempotencyKey()
scannedPallet.value = null
usageResult.value = null
lineOptions.value = []
tunnelOptions.value = []
lineLocked.value = false
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
tunnelLocked.value = false
form.value = { qrToken: '', tunnelId: undefined, lineId: undefined, usedPackages: undefined, fifoSkipReason: '' }
const scanContext = { generation: ++scanGeneration, operationSession, qrToken }
scanLoading.value = true
operationError.value = ''
lineWarning.value = ''
fifoRecommendation.value = null
fifoWarning.value = ''
scanUsagePallet(qrToken).then((response) => {
if (!isCurrentScanRequest(scanContext)) return
const pallet = response.data
@@ -336,21 +706,29 @@ function handleUsageScan(qrToken) {
strainCode: pallet.strainCode,
strainName: pallet.strainName,
batchNo: pallet.batchNo,
productionDate: pallet.productionDate,
expiryDate: pallet.expiryDate,
tunnelId: pallet.tunnelId,
tunnelName: pallet.tunnelName,
tunnelCode: pallet.tunnelCode,
traceNo: pallet.traceNo,
remainingPackages: pallet.remainingPackages,
flowState: pallet.flowState
}
form.value.qrToken = qrToken
form.value.qrToken = pallet.qrToken || qrToken
form.value.usedPackages = undefined
tunnelLocked.value = pallet.tunnelId != null
form.value.tunnelId = pallet.tunnelId ?? undefined
if (pallet.currentLineId == null) {
lineLocked.value = false
form.value.lineId = undefined
lineOptions.value = []
loadLineOptions(pallet, scanContext)
} else {
lineLocked.value = true
form.value.lineId = pallet.currentLineId
lineOptions.value = [{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `#${pallet.currentLineId}` }]
}
loadUsageOptions(pallet, scanContext)
}).catch(() => {
if (!isCurrentScanRequest(scanContext)) return
operationError.value = '托盘扫码校验失败,请检查后重试'
@@ -361,65 +739,164 @@ function handleUsageScan(qrToken) {
}
function handleScanInvalid(message) {
if (operationPending.value || requiresRecovery()) return
invalidateOptionRequest()
invalidateScanRequest()
clearUsageIdempotencyKey()
clearEntryIdempotencyKey()
scannedPallet.value = null
usageResult.value = null
lineOptions.value = []
tunnelOptions.value = []
lineLocked.value = false
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
tunnelLocked.value = false
form.value = { qrToken: '', tunnelId: undefined, lineId: undefined, usedPackages: undefined, fifoSkipReason: '' }
operationError.value = message
lineWarning.value = ''
fifoRecommendation.value = null
fifoWarning.value = ''
}
function handleScanDuplicate() {
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
}
function enterTunnel() {
if (!pageActive || !canAddUsage.value || operationPending.value || scanLoading.value || optionLoading.value || requiresRecovery() || !scannedPallet.value || tunnelLocked.value) return
if (!form.value.tunnelId || !form.value.qrToken) {
operationError.value = '请选择隧道后再扫码入隧'
return
}
const fifoSkipReason = validatedFifoSkipReason()
if (fifoSkipReason === null) return
const record = savePendingOperation('entry', {
qrToken: form.value.qrToken,
tunnelId: form.value.tunnelId,
...(fifoSkipReason ? { fifoSkipReason } : {}),
idempotencyKey: ensureEntryIdempotencyKey()
})
if (record) return executeOperation(record)
}
function submitUsage() {
if (operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return
if (!pageActive || !canAddUsage.value || operationPending.value || scanLoading.value || optionLoading.value || requiresRecovery() || usageResult.value) return
const context = { session: operationSession, storageKey: pendingStorageKey.value }
submitLoading.value = true
proxy.$refs.usageRef.validate((valid) => {
if (!isCurrentOperationRequest(context)) return
if (!valid) {
submitLoading.value = false
return
}
if (!scannedPallet.value || !form.value.qrToken) {
operationError.value = '请先扫描待使用托盘'
if (!scannedPallet.value || !form.value.qrToken || !tunnelLocked.value || !form.value.tunnelId) {
operationError.value = '请先扫描待使用托盘并完成入隧'
submitLoading.value = false
return
}
const idempotencyKey = ensureUsageIdempotencyKey()
operationError.value = ''
addUsage({
qrToken: form.value.qrToken,
lineId: form.value.lineId,
usedPackages: form.value.usedPackages,
idempotencyKey: idempotencyKey
}).then((response) => {
const result = response.data;
usageResult.value = {
remainingPackages: result.remainingPackages,
flowState: result.flowState
}
clearUsageIdempotencyKey()
proxy.$modal.msgSuccess(response.msg || '使用记录提交成功')
getList()
}).catch(() => {
operationError.value = '使用提交失败;直接重试将复用本次幂等键'
}).finally(() => {
if (!Number.isInteger(form.value.usedPackages) || form.value.usedPackages <= 0 || form.value.usedPackages > scannedPallet.value.remainingPackages) {
operationError.value = '使用包数必须是正整数且不能超过当前余量'
submitLoading.value = false
return
}
if (requiresRecovery()) {
submitLoading.value = false
return
}
const fifoSkipReason = validatedFifoSkipReason()
if (fifoSkipReason === null) {
submitLoading.value = false
return
}
const record = savePendingOperation('usage', {
qrToken: form.value.qrToken,
tunnelId: form.value.tunnelId,
lineId: form.value.lineId || null,
usedPackages: form.value.usedPackages,
...(fifoSkipReason ? { fifoSkipReason } : {}),
idempotencyKey: ensureUsageIdempotencyKey()
})
if (record) executeOperation(record)
else submitLoading.value = false
})
}
watch(
() => [form.value.qrToken, form.value.lineId, form.value.usedPackages],
() => [form.value.qrToken, form.value.tunnelId, form.value.lineId, form.value.usedPackages, form.value.fifoSkipReason],
() => {
if (!submitLoading.value) clearUsageIdempotencyKey()
if (!submitLoading.value && !pendingOperation.value) clearUsageIdempotencyKey()
}
)
getList();
watch(
() => [form.value.qrToken, form.value.tunnelId, form.value.fifoSkipReason],
() => {
if (!entryLoading.value && !pendingOperation.value) clearEntryIdempotencyKey()
}
)
watch(
() => [form.value.qrToken, form.value.tunnelId],
([qrToken, tunnelId], [oldQrToken, oldTunnelId]) => {
if (qrToken === oldQrToken && tunnelId === oldTunnelId) return
fifoRecommendation.value = null
fifoWarning.value = ''
loadFifoRecommendation()
},
// 目标失效必须同步完成,避免恢复表单后排队的 watcher 清掉写接口已确认的必填要求。
{ flush: 'sync' }
)
function leaveUsagePage() {
pageActive = false
operationSession += 1
listGeneration += 1
invalidateScanRequest()
invalidateOptionRequest()
entryLoading.value = false
submitLoading.value = false
loading.value = false
releaseLoading.value = false
pendingOperation.value = null
// 只清理页面状态,本机未确认记录留给再次进入时恢复。
scannedPallet.value = null
usageResult.value = null
fifoRecommendation.value = null
fifoWarning.value = ''
lineWarning.value = ''
operationError.value = ''
storageError.value = ''
lineOptions.value = []
tunnelOptions.value = []
lineLocked.value = false
tunnelLocked.value = false
form.value = { qrToken: '', tunnelId: undefined, lineId: undefined, usedPackages: undefined, fifoSkipReason: '' }
usageList.value = []
selectedIds.value = []
total.value = 0
clearUsageIdempotencyKey()
clearEntryIdempotencyKey()
}
watch(pendingStorageKey, () => {
const wasActive = pageActive
leaveUsagePage()
pageActive = wasActive
if (pageActive) {
restorePendingOperation()
getList()
}
}, { flush: 'sync' })
onDeactivated(leaveUsagePage)
onBeforeUnmount(leaveUsagePage)
onActivated(() => {
pageActive = true
restorePendingOperation()
getList()
})
restorePendingOperation()
getList()
</script>
<style lang="scss" scoped>
@@ -273,6 +273,7 @@ const inventoryStateLabels = {
UNSTOCKED: '未入库',
EMPTY_IN_WAREHOUSE: '仓库空托盘',
FILLED_IN_WAREHOUSE: '仓库有料',
IN_TRANSIT: '在途待收货',
AT_SITE_UNUSED: '现场未使用',
AT_SITE_PARTIAL: '现场部分使用',
AT_SITE_EMPTY: '现场空托盘',
+382 -179
View File
@@ -1,8 +1,8 @@
<template>
<div class="login login-wrapper">
<div class="login login-wrapper" @click="skipIntro" @keydown="skipIntro">
<canvas ref="canvasRef" class="bg-canvas"></canvas>
<div class="noise-overlay"></div>
<div class="login-box">
<div class="login-box" :class="{ 'is-visible': cardVisible }">
<div class="login-card">
<div class="brand-header">
<div class="brand-logo-wrap">
@@ -91,8 +91,13 @@
</div>
</div>
<!-- 底部 -->
<div class="el-login-footer">
<div class="el-login-footer" :class="{ 'is-visible': cardVisible }">
<span>{{ footerContent }}</span>
<span class="footer-divider" aria-hidden="true">|</span>
<router-link
class="open-source-link"
to="/open-source-notice"
>开源协议</router-link>
</div>
</div>
</template>
@@ -108,6 +113,29 @@ import brandLogoLight from '@/assets/logo/sylvan-logo-light.svg'
const title = import.meta.env.VITE_APP_TITLE;
const footerContent = defaultSettings.footerContent
const cardVisible = ref(false);
let bgController = null;
let cardTimer = null;
function revealCardSmoothly() {
if (!cardVisible.value) {
cardVisible.value = true;
if (cardTimer) {
clearTimeout(cardTimer);
cardTimer = null;
}
}
}
function skipIntro() {
if (cardVisible.value) return;
if (bgController && bgController.fastForward) {
bgController.fastForward();
}
revealCardSmoothly();
}
const userStore = useUserStore();
const route = useRoute();
const router = useRouter();
@@ -200,178 +228,297 @@ function getCookie() {
const canvasRef = ref(null);
let cleanupBackground = null;
class GeometricShape {
constructor(anchorXRatio, anchorYRatio, radiusRatio, color, style = 'fill', ringCount = 1) {
this.anchorXRatio = anchorXRatio;
this.anchorYRatio = anchorYRatio;
this.radiusRatio = radiusRatio;
this.color = color;
this.style = style;
this.ringCount = ringCount;
this.phaseX = Math.random() * Math.PI * 2;
this.phaseY = Math.random() * Math.PI * 2;
this.phaseR = Math.random() * Math.PI * 2;
this.speed = 0.0003 + Math.random() * 0.0002;
function initDynamicBackground(options = {}) {
const canvas = canvasRef.value;
if (!canvas) return null;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
let w = 0;
let h = 0;
let dpr = 1;
let animationFrameId = null;
let started = performance.now();
let cardNotified = false;
const introHold = 850;
const scatterMs = 2900;
const SVG_NS = 'http://www.w3.org/2000/svg';
const sampler = document.createElementNS(SVG_NS, 'svg');
sampler.setAttribute('aria-hidden', 'true');
sampler.style.cssText = 'position:fixed;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none;';
document.body.appendChild(sampler);
const leaves = [
{ at: [91, 35], r: 49, to: [.95, -.05], size: .20, delay: .02, arc: .09, rot: .04, color: '#00A89D', darkColor: '#008596' },
{ at: [28, 50], r: 35, to: [-.02, .15], size: .35, delay: .11, arc: -.10, rot: -.05, color: '#262464', darkColor: '#162238' },
{ at: [99, 77], r: 48, to: [.08, -.05], size: .22, delay: .07, arc: .08, rot: .04, color: '#00A89D', darkColor: '#008596' },
{ at: [95, 111], r: 31, to: [.98, .88], size: .15, delay: .14, arc: -.07, rot: -.04, color: '#00A89D', darkColor: '#008596' },
{ at: [76, 111], r: 34, to: [.85, .95], size: .25, delay: .04, arc: .06, rot: .05, color: '#262464', darkColor: '#162238' },
{ at: [32, 112], r: 37, to: [.05, 1.00], size: .18, delay: .09, arc: -.08, rot: -.04, color: '#6ec6c2', darkColor: '#0e4e5e' }
];
const rawLogoPaths = [
{ d: "M 57.828125 67.277344 C 57.792969 72.289062 56.96875 77.109375 55.484375 81.628906 L 55.503906 81.652344 L 55.519531 81.65625 C 66.308594 50.636719 93.671875 27.382812 127.007812 22.496094 C 130.695312 15.828125 132.804688 8.160156 132.804688 0.00390625 L 46.738281 0.00390625 C 30.890625 0.00390625 16.90625 7.925781 8.496094 20.019531 C 9.371094 19.976562 10.25 19.953125 11.136719 19.957031 C 37.097656 20.132812 58 41.316406 57.828125 67.277344 ", c: "#36bbab", leaf: 0 },
{ d: "M 0.1875 46.554688 C 0.1875 52.15625 1.1875 57.523438 3.003906 62.5 C 8.207031 61.726562 13.597656 61.628906 19.070312 62.328125 C 33.730469 64.207031 46.414062 71.359375 55.484375 81.628906 C 56.96875 77.109375 57.792969 72.289062 57.828125 67.277344 C 58 41.316406 37.097656 20.132812 11.136719 19.957031 C 10.25 19.953125 9.371094 19.976562 8.496094 20.019531 C 3.261719 27.546875 0.1875 36.6875 0.1875 46.554688 ", c: "#256d8c", leaf: 1 },
{ d: "M 55.519531 81.65625 L 55.53125 81.65625 C 61.101562 80.082031 66.972656 79.222656 73.046875 79.222656 C 96.988281 79.222656 117.855469 92.324219 128.914062 111.738281 C 131.410156 106.03125 132.804688 99.730469 132.804688 93.105469 C 132.804688 68.730469 114.074219 48.730469 90.21875 46.71875 C 89.996094 46.699219 89.996094 46.40625 90.21875 46.386719 C 106.085938 45.046875 119.679688 35.746094 127.007812 22.496094 C 93.671875 27.382812 66.308594 50.636719 55.519531 81.65625 ", c: "#2e8f99", leaf: 2 },
{ d: "M 73.046875 79.222656 C 66.972656 79.222656 61.101562 80.082031 55.53125 81.65625 C 79.742188 89.105469 97.339844 111.648438 97.339844 138.300781 L 97.339844 138.316406 C 111.53125 134.847656 123.164062 124.878906 128.914062 111.738281 C 117.855469 92.324219 96.988281 79.222656 73.046875 79.222656 ", c: "#26728b", leaf: 3 },
{ d: "M 86.253906 139.65625 L 66.613281 139.65625 C 68.121094 135.890625 69.183594 131.9375 69.710938 127.828125 C 70.03125 125.3125 70.1875 122.816406 70.1875 120.347656 C 70.191406 105.730469 64.71875 92.09375 55.515625 81.664062 L 55.53125 81.65625 C 79.742188 89.105469 97.339844 111.648438 97.339844 138.300781 L 97.339844 138.316406 C 93.785156 139.183594 90.078125 139.65625 86.253906 139.65625 ", c: "#27517b", leaf: 4 },
{ d: "M 55.53125 81.65625 L 55.515625 81.664062 C 66.273438 93.855469 71.933594 110.433594 69.710938 127.828125 C 69.183594 131.9375 68.121094 135.890625 66.613281 139.65625 L 86.253906 139.65625 C 90.078125 139.65625 93.785156 139.183594 97.339844 138.316406 L 97.339844 138.300781 C 97.339844 111.648438 79.742188 89.105469 55.53125 81.65625 ", c: "#2a4a7a", leaf: 4 },
{ d: "M 55.515625 81.664062 L 55.46875 81.675781 L 55.480469 81.644531 L 55.484375 81.628906 C 46.414062 71.359375 33.730469 64.203125 19.070312 62.328125 C 13.597656 61.628906 8.207031 61.726562 3 62.5 C 5.921875 70.5 10.964844 77.476562 17.460938 82.738281 C 22.5625 86.871094 28.554688 89.941406 35.105469 91.625 C 37.546875 92.253906 40.058594 92.703125 42.636719 92.925781 C 42.878906 92.945312 42.878906 93.261719 42.636719 93.28125 C 37.914062 93.695312 33.394531 94.824219 29.183594 96.539062 C 20.496094 100.082031 13.121094 106.167969 7.984375 113.878906 C 3.0625 121.261719 0.1875 130.117188 0.1875 139.65625 L 66.613281 139.65625 C 68.121094 135.890625 69.183594 131.9375 69.710938 127.828125 C 71.933594 110.433594 66.273438 93.855469 55.515625 81.664062 ", c: "#1e2567", leaf: 5 }
];
const logoPaths = rawLogoPaths.map(p => {
const v = document.createElementNS(SVG_NS, 'path');
v.setAttribute('d', p.d);
sampler.appendChild(v);
const length = v.getTotalLength();
const count = 240;
const points = [];
for (let i = 0; i < count; i++) {
const pt = v.getPointAtLength(length * i / count);
points.push([pt.x, pt.y]);
}
let area = 0;
for (let i = 0; i < count; i++) {
const a = points[i];
const b = points[(i + 1) % count];
area += a[0] * b[1] - b[0] * a[1];
}
return {
...p,
points,
direction: area >= 0 ? 1 : -1,
startAngle: Math.atan2(points[0][1] - leaves[p.leaf].at[1], points[0][0] - leaves[p.leaf].at[0])
};
});
sampler.remove();
const clamp = t => Math.max(0, Math.min(1, t));
const ease = t => {
t = clamp(t);
return t * t * t * (t * (t * 6 - 15) + 10);
};
function parseHex(hex) {
let c = hex.replace('#', '');
if (c.length === 3) c = c.split('').map(x => x + x).join('');
return [
parseInt(c.slice(0, 2), 16),
parseInt(c.slice(2, 4), 16),
parseInt(c.slice(4, 6), 16)
];
}
draw(ctx, w, h, t) {
const baseX = w * this.anchorXRatio;
const baseY = h * this.anchorYRatio;
const maxDim = Math.max(w, h);
const baseRadius = maxDim * this.radiusRatio;
function colorMix(from, to, t) {
const a = parseHex(from);
const b = parseHex(to);
const r = Math.round(a[0] + (b[0] - a[0]) * t);
const g = Math.round(a[1] + (b[1] - a[1]) * t);
const bl = Math.round(a[2] + (b[2] - a[2]) * t);
return `rgb(${r},${g},${bl})`;
}
const x = baseX + Math.sin(t * this.speed + this.phaseX) * (maxDim * 0.015);
const y = baseY + Math.cos(t * this.speed * 0.8 + this.phaseY) * (maxDim * 0.015);
const r = baseRadius + Math.sin(t * this.speed * 1.2 + this.phaseR) * (baseRadius * 0.03);
function shade(color, amount) {
const rgb = color.startsWith('#')
? color.match(/[\da-f]{2}/gi).map(v => parseInt(v, 16))
: color.match(/[\d.]+/g).slice(0, 3).map(Number);
return `rgb(${rgb.map(v => Math.round(amount >= 0 ? v + (255 - v) * amount : v * (1 + amount))).join(',')})`;
}
function resize() {
if (!canvas) return;
w = window.innerWidth;
h = window.innerHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
function drawBackground(t, progress, isDark) {
const reveal = ease((progress - 0.66) / 0.34);
ctx.fillStyle = isDark ? '#0b1420' : '#f0f6f7';
ctx.fillRect(0, 0, w, h);
const m = Math.max(w, h);
const currentGlows = isDark ? [
{ x: .3, y: .7, r: .5, c: '0, 133, 150', a: .12, sx: .0001, sy: .00015 },
{ x: .7, y: .3, r: .6, c: '22, 34, 56', a: .18, sx: .00012, sy: .00008 },
{ x: .5, y: .5, r: .7, c: '14, 78, 94', a: .10, sx: -.00008, sy: .00011 }
] : [
{ x: .3, y: .7, r: .5, c: '0, 168, 157', a: .08, sx: .0001, sy: .00015 },
{ x: .7, y: .3, r: .6, c: '70, 80, 150', a: .06, sx: .00012, sy: .00008 },
{ x: .5, y: .5, r: .7, c: '110, 198, 194', a: .07, sx: -.00008, sy: .00011 }
];
for (const g of currentGlows) {
const x = w * g.x + Math.sin(t * g.sx) * w * 0.2;
const y = h * g.y + Math.cos(t * g.sy) * h * 0.2;
const r = m * g.r;
const gr = ctx.createRadialGradient(x, y, 0, x, y, r);
gr.addColorStop(0, `rgba(${g.c},${g.a})`);
gr.addColorStop(1, `rgba(${g.c},0)`);
ctx.globalAlpha = 0.5 + 0.5 * reveal;
ctx.fillStyle = gr;
ctx.fillRect(x - r, y - r, 2 * r, 2 * r);
}
ctx.globalAlpha = 1;
}
function drawLogo(progress, now, isDark) {
const scale = Math.min(w * 0.82 / 133, h * 0.64 / 140, 3.4);
const ox = (w - 133 * scale) / 2;
const oy = (h - 140 * scale) / 2;
const m = Math.max(w, h);
logoPaths.forEach(p => {
const q = leaves[p.leaf];
const targetColor = isDark ? q.darkColor : q.color;
const local = clamp((progress - q.delay) / (1 - q.delay));
const move = ease(local);
const sx = ox + q.at[0] * scale;
const sy = oy + q.at[1] * scale;
const phase = p.leaf * 1.71;
const driftX = Math.sin(now * 0.00035 + phase) * m * 0.008;
const driftY = Math.cos(now * 0.00029 + phase) * m * 0.008;
const tx = w * q.to[0] + driftX;
const ty = h * q.to[1] + driftY;
const dx = tx - sx;
const dy = ty - sy;
const len = Math.hypot(dx, dy) || 1;
const arc = Math.sin(Math.PI * move) * q.arc * m;
const pulse = 1 + 0.025 * Math.sin(now * 0.001 + phase);
const targetRadius = m * q.size * pulse;
const centerX = sx + dx * move - (dy / len) * arc;
const centerY = sy + dy * move + (dx / len) * arc;
const visualRadius = q.r * scale + (targetRadius - q.r * scale) * move;
if (this.style === 'fill') {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = this.color;
p.points.forEach(([px, py], i) => {
const theta = p.startAngle + p.direction * (Math.PI * 2 * i / p.points.length) + q.rot * move;
const x0 = ox + px * scale;
const y0 = oy + py * scale;
const x1 = tx + Math.cos(theta) * targetRadius;
const y1 = ty + Math.sin(theta) * targetRadius;
const x = x0 + (x1 - x0) * move - (dy / len) * arc;
const y = y0 + (y1 - y0) * move + (dx / len) * arc;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.closePath();
ctx.save();
const base = colorMix(p.c, targetColor, move);
const light = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, Math.max(2, visualRadius * 1.25));
light.addColorStop(0, base);
light.addColorStop(0.52, base);
light.addColorStop(0.82, shade(base, -0.10 * move));
light.addColorStop(1, shade(base, -0.24 * move));
ctx.fillStyle = light;
ctx.shadowColor = isDark ? 'rgba(0, 0, 0, 0.45)' : 'rgba(13, 23, 67, 0.32)';
ctx.shadowBlur = 28;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 13;
ctx.fill();
} else if (this.style === 'stroke') {
for (let i = 0; i < this.ringCount; i++) {
ctx.shadowColor = isDark ? 'rgba(0, 0, 0, 0.25)' : 'rgba(13, 23, 67, 0.18)';
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 3;
ctx.fill();
ctx.restore();
});
}
function drawRings(progress, isDark) {
const reveal = ease((progress - 0.56) / 0.32);
const m = Math.max(w, h);
ctx.globalAlpha = reveal;
const currentRings = isDark ? [
[-.1, -.1, .25, 'rgba(56, 189, 248, .12)', 6],
[1.05, 1.05, .15, 'rgba(56, 189, 248, .18)', 4]
] : [
[-.1, -.1, .25, 'rgba(255, 255, 255, .24)', 6],
[1.05, 1.05, .15, 'rgba(0, 168, 157, .30)', 4]
];
for (const [a, b, r, c, n] of currentRings) {
const x = w * a;
const y = h * b;
for (let i = 0; i < n; i++) {
ctx.beginPath();
const ringR = r + i * (maxDim * 0.025);
ctx.arc(x, y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = this.color;
ctx.arc(x, y, m * r + i * m * 0.025, 0, Math.PI * 2);
ctx.strokeStyle = c;
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
}
}
class AmbientGlow {
constructor(xRatio, yRatio, radiusRatio, r, g, b, maxOpacity, speedX, speedY) {
this.xRatio = xRatio;
this.yRatio = yRatio;
this.radiusRatio = radiusRatio;
this.color = `${r}, ${g}, ${b}`;
this.maxOpacity = maxOpacity;
this.speedX = speedX;
this.speedY = speedY;
this.phaseX = Math.random() * Math.PI * 2;
this.phaseY = Math.random() * Math.PI * 2;
ctx.globalAlpha = 1;
}
draw(ctx, w, h, t) {
const maxDim = Math.max(w, h);
const radius = maxDim * this.radiusRatio;
const x = w * this.xRatio + Math.sin(t * this.speedX + this.phaseX) * (w * 0.2);
const y = h * this.yRatio + Math.cos(t * this.speedY + this.phaseY) * (h * 0.2);
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, `rgba(${this.color}, ${this.maxOpacity})`);
gradient.addColorStop(1, `rgba(${this.color}, 0)`);
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
}
}
function initDynamicBackground() {
const canvas = canvasRef.value;
if (!canvas) return null;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return null;
let width = 0;
let height = 0;
let time = 0;
let animationFrameId = null;
function resizeCanvas() {
if (!canvas) return;
width = window.innerWidth;
height = window.innerHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
const isDark = document.documentElement.classList.contains('dark');
const shapes = isDark ? [
new GeometricShape(-0.02, 0.15, 0.35, '#162238', 'fill'),
new GeometricShape(0.08, -0.05, 0.22, '#008596', 'fill'),
new GeometricShape(-0.1, -0.1, 0.25, 'rgba(56, 189, 248, 0.08)', 'stroke', 6),
new GeometricShape(0.85, 0.95, 0.25, '#162238', 'fill'),
new GeometricShape(0.98, 0.88, 0.15, '#008596', 'fill'),
new GeometricShape(1.05, 1.05, 0.15, 'rgba(56, 189, 248, 0.12)', 'stroke', 4),
new GeometricShape(0.05, 1.0, 0.18, '#0e4e5e', 'fill'),
new GeometricShape(0.95, -0.05, 0.2, '#008596', 'fill')
] : [
new GeometricShape(-0.02, 0.15, 0.35, '#262464', 'fill'),
new GeometricShape(0.08, -0.05, 0.22, '#00A89D', 'fill'),
new GeometricShape(-0.1, -0.1, 0.25, 'rgba(255, 255, 255, 0.08)', 'stroke', 6),
new GeometricShape(0.85, 0.95, 0.25, '#262464', 'fill'),
new GeometricShape(0.98, 0.88, 0.15, '#00A89D', 'fill'),
new GeometricShape(1.05, 1.05, 0.15, 'rgba(0, 168, 157, 0.15)', 'stroke', 4),
new GeometricShape(0.05, 1.0, 0.18, '#6ec6c2', 'fill'),
new GeometricShape(0.95, -0.05, 0.2, '#00A89D', 'fill')
];
const glows = isDark ? [
new AmbientGlow(0.3, 0.7, 0.5, 0, 133, 150, 0.12, 0.0001, 0.00015),
new AmbientGlow(0.7, 0.3, 0.6, 22, 34, 56, 0.18, 0.00012, 0.00008),
new AmbientGlow(0.5, 0.5, 0.7, 14, 78, 94, 0.10, -0.00008, 0.00011)
] : [
new AmbientGlow(0.3, 0.7, 0.5, 0, 168, 157, 0.08, 0.0001, 0.00015),
new AmbientGlow(0.7, 0.3, 0.6, 70, 80, 150, 0.06, 0.00012, 0.00008),
new AmbientGlow(0.5, 0.5, 0.7, 110, 198, 194, 0.07, -0.00008, 0.00011)
];
function drawBackground() {
const isNowDark = document.documentElement.classList.contains('dark');
ctx.fillStyle = isNowDark ? '#0b1420' : '#f0f6f7';
ctx.fillRect(0, 0, width, height);
}
function animate() {
drawBackground();
glows.forEach(glow => {
glow.draw(ctx, width, height, time);
});
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
shapes.forEach(shape => {
shape.draw(ctx, width, height, time);
});
time += 16;
animationFrameId = requestAnimationFrame(animate);
}
animationFrameId = requestAnimationFrame(animate);
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
function frame(now) {
const isDark = document.documentElement.classList.contains('dark');
const progress = clamp((now - started - introHold) / scatterMs);
if (!cardNotified && progress >= 0.72) {
cardNotified = true;
if (typeof options.onCardReady === 'function') {
options.onCardReady();
}
}
window.removeEventListener('resize', resizeCanvas);
drawBackground(now, progress, isDark);
drawLogo(progress, now, isDark);
drawRings(progress, isDark);
animationFrameId = requestAnimationFrame(frame);
}
animationFrameId = requestAnimationFrame(frame);
function fastForward() {
started = performance.now() - introHold - scatterMs;
if (!cardNotified) {
cardNotified = true;
if (typeof options.onCardReady === 'function') {
options.onCardReady();
}
}
}
return {
cleanup: () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
window.removeEventListener('resize', resize);
},
fastForward
};
}
onMounted(() => {
cleanupBackground = initDynamicBackground();
bgController = initDynamicBackground({
onCardReady: () => {
revealCardSmoothly();
}
});
cardTimer = setTimeout(revealCardSmoothly, 3200);
});
onBeforeUnmount(() => {
if (cleanupBackground) {
cleanupBackground();
cleanupBackground = null;
if (bgController && bgController.cleanup) {
bgController.cleanup();
bgController = null;
}
if (cardTimer) {
clearTimeout(cardTimer);
cardTimer = null;
}
});
@@ -421,25 +568,41 @@ html.dark .login-wrapper {
position: relative;
z-index: 2;
width: 100%;
max-width: 440px;
max-width: 430px;
padding: 20px;
opacity: 0;
filter: blur(10px);
transform: translateY(24px) scale(0.96);
transition:
opacity 1.05s cubic-bezier(0.22, 1, 0.36, 1),
transform 1.05s cubic-bezier(0.22, 1, 0.36, 1),
filter 0.95s cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: none;
will-change: opacity, transform, filter;
&.is-visible {
opacity: 1;
filter: blur(0);
transform: translateY(0) scale(1);
pointer-events: auto;
}
}
.login-card {
background: rgba(255, 255, 255, 0.90);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.85);
border-radius: 20px;
padding: 38px 36px 30px;
box-shadow: 0 20px 60px rgba(15, 34, 58, 0.12), 0 4px 16px rgba(15, 34, 58, 0.05);
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 36px 36px 28px;
box-shadow: 0 4px 6px -1px rgba(15, 23, 42, 0.05), 0 20px 48px -10px rgba(15, 23, 42, 0.10);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
html.dark .login-card {
background: rgba(23, 27, 34, 0.92);
background: rgba(21, 29, 44, 0.98);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55), 0 4px 16px rgba(0, 0, 0, 0.3);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55), 0 4px 16px rgba(0, 0, 0, 0.3);
}
.brand-header {
@@ -455,10 +618,10 @@ html.dark .login-card {
}
.brand-logo {
height: 56px;
height: 52px;
max-width: 240px;
object-fit: contain;
filter: drop-shadow(0 4px 10px rgba(15, 34, 58, 0.08));
filter: drop-shadow(0 2px 6px rgba(15, 34, 58, 0.08));
transition: transform 0.25s ease, filter 0.25s ease;
&:hover {
@@ -491,10 +654,10 @@ html.dark {
.system-title {
margin: 0;
font-size: 22px;
font-size: 21px;
font-weight: 700;
letter-spacing: 0.5px;
color: #1a2736;
color: #0f172a;
}
html.dark .system-title {
@@ -540,10 +703,10 @@ html.dark .badge-divider {
}
:deep(.el-input__wrapper) {
border-radius: 10px;
border-radius: 8px;
padding: 2px 14px;
height: 44px;
box-shadow: 0 0 0 1px #dcdfe6 inset;
box-shadow: 0 0 0 1px #cbd5e1 inset;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
&:hover {
@@ -616,9 +779,9 @@ html.dark .login-form {
width: 120px;
height: 44px;
flex: 0 0 120px;
border-radius: 10px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #dcdfe6;
border: 1px solid #cbd5e1;
background: #f8fafc;
cursor: pointer;
display: flex;
@@ -674,25 +837,25 @@ html.dark .register-link {
.login-submit-btn {
width: 100%;
height: 46px;
border-radius: 10px;
height: 44px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
letter-spacing: 2px;
border: none;
background: linear-gradient(135deg, #008596 0%, #0284c7 100%);
box-shadow: 0 8px 22px rgba(0, 133, 150, 0.32);
background: linear-gradient(135deg, #008596 0%, #009aa9 100%);
box-shadow: 0 4px 14px rgba(0, 133, 150, 0.28);
transition: all 0.25s ease;
&:hover {
background: linear-gradient(135deg, #009cb0 0%, #0396dc 100%);
box-shadow: 0 10px 26px rgba(0, 133, 150, 0.42);
background: linear-gradient(135deg, #009aa9 0%, #00adb9 100%);
box-shadow: 0 6px 18px rgba(0, 133, 150, 0.38);
transform: translateY(-1px);
}
&:active {
transform: translateY(1px);
box-shadow: 0 4px 12px rgba(0, 133, 150, 0.28);
box-shadow: 0 2px 8px rgba(0, 133, 150, 0.25);
}
}
@@ -701,18 +864,58 @@ html.dark .register-link {
bottom: 16px;
left: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
text-align: center;
color: #64748b;
font-family: inherit;
font-size: 12px;
letter-spacing: 0.5px;
z-index: 2;
opacity: 0;
transform: translateY(10px);
transition:
opacity 0.85s ease 0.25s,
transform 0.85s cubic-bezier(0.22, 1, 0.36, 1) 0.25s;
&.is-visible {
opacity: 1;
transform: translateY(0);
}
}
html.dark .el-login-footer {
color: #94a3b8;
}
.footer-divider {
color: #cbd5e1;
user-select: none;
}
.open-source-link {
color: inherit;
text-decoration: none;
transition: color 0.2s ease, text-decoration-color 0.2s ease;
&:hover {
color: #008596;
text-decoration: underline;
text-underline-offset: 2px;
}
}
html.dark .footer-divider {
color: #475569;
}
html.dark .open-source-link:hover {
color: #38bdf8;
}
@media (max-width: 480px) {
.login-box {
padding: 14px;
@@ -0,0 +1,180 @@
<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="现场ID" prop="siteId">
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="请输入现场ID" />
</el-form-item>
<el-form-item label="隧道编号" prop="tunnelCode">
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="隧道名称" prop="tunnelName">
<el-input v-model="queryParams.tunnelName" placeholder="请输入隧道名称" clearable maxlength="100" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 160px">
<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>
</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:tunnel:add']">新增</el-button></el-col>
<el-col :span="1.5">
<el-button
v-if="proxy.$auth.hasPermiAnd(['master:tunnel:query', 'master:tunnel:edit'])"
type="success" plain icon="Edit" :disabled="single || detailLoading" :loading="detailLoading"
@click="handleUpdate()" v-hasPermi="['master:tunnel: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>
<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="tunnelList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="隧道ID" prop="tunnelId" align="center" width="90" />
<el-table-column label="现场ID" prop="siteId" align="center" width="100" />
<el-table-column label="隧道编号" prop="tunnelCode" align="center" :show-overflow-tooltip="true" />
<el-table-column label="隧道名称" prop="tunnelName" align="center" :show-overflow-tooltip="true" />
<el-table-column label="当前溯源号" prop="currentTraceNo" align="center" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.currentTraceNo || '暂无物料' }}</template>
</el-table-column>
<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: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(520px, 92vw)" append-to-body :close-on-click-modal="!submitLoading" :close-on-press-escape="!submitLoading" :show-close="!submitLoading">
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
<el-form ref="tunnelRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="现场ID" prop="siteId"><el-input-number v-model="form.siteId" :min="1" :controls="false" /></el-form-item>
<el-form-item label="隧道编号" prop="tunnelCode"><el-input v-model="form.tunnelCode" placeholder="请输入隧道编号" maxlength="64" show-word-limit /></el-form-item>
<el-form-item label="隧道名称" prop="tunnelName"><el-input v-model="form.tunnelName" placeholder="请输入隧道名称" maxlength="100" show-word-limit /></el-form-item>
<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></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">
<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>
<el-button :disabled="submitLoading" @click="cancel">取 消</el-button>
</div>
</template>
</el-dialog>
</div>
</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; } }
</style>
<script setup name="Tunnel">
import { addTunnel, getTunnel, listTunnel, updateTunnel } from '@/api/master/tunnel'
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
import useUserStore from '@/store/modules/user'
import useTestBatchStore from '@/store/modules/testBatch'
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 data = reactive({
form: {},
queryParams: { pageNum: 1, pageSize: 10, siteId: undefined, tunnelCode: undefined, tunnelName: undefined, status: undefined },
rules: {
siteId: [{ required: true, message: '请输入现场ID', trigger: 'change' }],
tunnelCode: [{ required: true, message: '请输入隧道编号', trigger: 'blur' }],
tunnelName: [{ required: true, message: '请输入隧道名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
})
const { form, queryParams, rules } = toRefs(data)
function getList() {
loading.value = true
loadError.value = ''
listTunnel({ ...queryParams.value }).then((response) => {
tunnelList.value = response.rows
total.value = response.total
}).catch(() => {
tunnelList.value = []
total.value = 0
loadError.value = '隧道列表加载失败,请稍后重试'
}).finally(() => { loading.value = false })
}
function reset() {
formError.value = ''
form.value = { tunnelId: undefined, siteId: undefined, tunnelCode: '', tunnelName: '', status: '0', remark: '' }
proxy.resetForm('tunnelRef')
}
function handleQuery() { queryParams.value.pageNum = 1; getList() }
function resetQuery() { proxy.resetForm('queryRef'); handleQuery() }
function handleSelectionChange(selection) { selectedIds.value = selection.map((item) => item.tunnelId); single.value = selectedIds.value.length !== 1 }
function openAdoptionDialog() { adoptionDialogRef.value?.open() }
function handleAdd() { reset(); title.value = '新增隧道'; open.value = true }
function handleUpdate(row = {}) {
const tunnelId = row.tunnelId || selectedIds.value[0]
if (!tunnelId) return
detailLoading.value = true
getTunnel(tunnelId).then((response) => {
const detail = response.data
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(() => { formError.value = '隧道详情加载失败,请稍后重试' }).finally(() => { detailLoading.value = false })
}
function cancel() { if (submitLoading.value) return; open.value = false; reset() }
function submitCreate() { submitForm(addTunnel, '新增成功') }
function submitUpdate() { submitForm(updateTunnel, '修改成功') }
function submitForm(action, successMessage) {
if (submitLoading.value) return
proxy.$refs.tunnelRef.validate((valid) => {
if (!valid) return
submitLoading.value = true
action({ ...form.value }).then(() => { proxy.$modal.msgSuccess(successMessage); open.value = false; reset(); getList() }).catch(() => { formError.value = '保存失败,请检查现场和隧道编号后重试' }).finally(() => { submitLoading.value = false })
})
}
getList()
</script>
@@ -0,0 +1,405 @@
<template>
<main class="open-source-page">
<div class="notice-container">
<header class="notice-header">
<router-link to="/login" class="brand-link" aria-label="返回登录页">
<img :src="brandLogoLight" alt="Sylvan" class="brand-logo brand-logo-light" />
<img :src="brandLogoDark" alt="Sylvan" class="brand-logo brand-logo-dark" />
</router-link>
<router-link to="/login" class="back-link">
<span aria-hidden="true">←</span>
返回登录
</router-link>
</header>
<section class="notice-heading" aria-labelledby="notice-title">
<p class="notice-eyebrow">THIRD-PARTY SOFTWARE NOTICE</p>
<h1 id="notice-title">开源协议说明</h1>
<p class="notice-summary">
本页面用于说明施尔丰系统中使用的第三方开源软件及其许可证信息。
</p>
</section>
<article class="notice-card">
<div class="notice-card-header">
<div>
<p class="notice-label">第三方来源代码</p>
<h2>RuoYi-Vue3-FastAPI</h2>
</div>
<span class="license-badge">MIT License</span>
</div>
<p class="notice-description">
本项目 Web 前端部分包含来源于 RuoYi-Vue3-FastAPI 项目的代码及其衍生部分,
相关代码保留原项目的版权声明与 MIT 许可证。
</p>
<div class="notice-meta">
<div class="meta-item">
<span class="meta-label">版权声明</span>
<strong>Copyright (c) 2018 RuoYi</strong>
</div>
<div class="meta-item">
<span class="meta-label">来源项目</span>
<a
href="https://github.com/insistence/RuoYi-Vue3-FastAPI"
target="_blank"
rel="noopener noreferrer"
>github.com/insistence/RuoYi-Vue3-FastAPI</a>
</div>
</div>
<div class="scope-note">
<span class="scope-note-icon" aria-hidden="true">ⓘ</span>
<span>施尔丰自有业务代码、界面设计和文档不因本声明而开放授权。</span>
</div>
<section class="license-section" aria-labelledby="mit-title">
<h3 id="mit-title">The MIT License (MIT)</h3>
<pre class="license-text">{{ ruoyiLicenseText }}</pre>
</section>
</article>
<footer class="notice-footer">
<span>Copyright © 2024-2026 Shierfeng International Biotechnology Co., Ltd. All Rights Reserved.</span>
</footer>
</div>
</main>
</template>
<script setup>
import brandLogoDark from '@/assets/logo/sylvan-logo.svg'
import brandLogoLight from '@/assets/logo/sylvan-logo-light.svg'
const ruoyiLicenseText = `Copyright (c) 2018 RuoYi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`
</script>
<style lang="scss" scoped>
.open-source-page {
min-height: 100vh;
color: #1a2736;
background:
radial-gradient(circle at 8% 12%, rgba(0, 168, 157, 0.14), transparent 32%),
radial-gradient(circle at 92% 86%, rgba(38, 36, 100, 0.13), transparent 34%),
#f5f9fa;
}
.notice-container {
width: min(100%, 1040px);
margin: 0 auto;
padding: 28px 24px 30px;
}
.notice-header,
.notice-card-header,
.notice-meta {
display: flex;
align-items: center;
}
.notice-header {
justify-content: space-between;
gap: 20px;
}
.brand-link {
display: inline-flex;
align-items: center;
}
.brand-logo {
width: 168px;
height: 48px;
object-fit: contain;
}
.brand-logo-light {
display: block;
}
.brand-logo-dark {
display: none;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 7px;
color: #008596;
font-size: 14px;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: #006d7a;
}
}
.notice-heading {
padding: 58px 0 28px;
text-align: center;
}
.notice-eyebrow,
.notice-label,
.meta-label {
margin: 0;
color: #008596;
font-size: 12px;
font-weight: 700;
letter-spacing: 1.6px;
text-transform: uppercase;
}
.notice-heading h1 {
margin: 12px 0 10px;
color: #152337;
font-size: clamp(28px, 4vw, 42px);
line-height: 1.2;
letter-spacing: 0.4px;
}
.notice-summary {
max-width: 620px;
margin: 0 auto;
color: #64748b;
font-size: 15px;
line-height: 1.8;
}
.notice-card {
padding: clamp(22px, 4vw, 40px);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 22px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 22px 60px rgba(15, 34, 58, 0.1), 0 4px 14px rgba(15, 34, 58, 0.05);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.notice-card-header {
justify-content: space-between;
gap: 18px;
}
.notice-card h2 {
margin: 8px 0 0;
color: #152337;
font-size: clamp(21px, 3vw, 28px);
}
.license-badge {
flex: 0 0 auto;
padding: 7px 12px;
border: 1px solid rgba(0, 133, 150, 0.2);
border-radius: 999px;
color: #008596;
background: rgba(0, 168, 157, 0.08);
font-size: 12px;
font-weight: 700;
}
.notice-description {
margin: 22px 0 0;
color: #475569;
font-size: 15px;
line-height: 1.8;
}
.notice-meta {
flex-wrap: wrap;
gap: 18px 48px;
margin-top: 26px;
padding: 18px 0;
border-top: 1px solid #e2e8f0;
border-bottom: 1px solid #e2e8f0;
}
.meta-item {
min-width: 240px;
}
.meta-label {
display: block;
margin-bottom: 7px;
color: #94a3b8;
font-size: 11px;
letter-spacing: 1px;
}
.meta-item strong,
.meta-item a {
color: #334155;
font-size: 13px;
line-height: 1.6;
}
.meta-item a {
color: #008596;
text-decoration: none;
overflow-wrap: anywhere;
&:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
}
.scope-note {
display: flex;
align-items: flex-start;
gap: 9px;
margin-top: 22px;
padding: 13px 15px;
border-radius: 12px;
color: #475569;
background: #f0f8f8;
font-size: 13px;
line-height: 1.7;
}
.scope-note-icon {
flex: 0 0 auto;
color: #008596;
font-size: 15px;
line-height: 1.5;
}
.license-section {
margin-top: 30px;
}
.license-section h3 {
margin: 0 0 14px;
color: #1e293b;
font-size: 16px;
}
.license-text {
max-height: 430px;
margin: 0;
padding: 20px;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 12px;
color: #475569;
background: #f8fafc;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.75;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.notice-footer {
padding: 26px 0 0;
color: #94a3b8;
font-size: 12px;
line-height: 1.6;
text-align: center;
}
html.dark .open-source-page {
color: #e2e8f0;
background:
radial-gradient(circle at 8% 12%, rgba(0, 133, 150, 0.2), transparent 32%),
radial-gradient(circle at 92% 86%, rgba(56, 189, 248, 0.12), transparent 34%),
#0b1420;
}
html.dark {
.brand-logo-light {
display: none;
}
.brand-logo-dark {
display: block;
}
.back-link,
.meta-item a {
color: #38bdf8;
}
.back-link:hover,
.meta-item a:hover {
color: #7dd3fc;
}
.notice-heading h1,
.notice-card h2,
.license-section h3 {
color: #f1f5f9;
}
.notice-summary,
.notice-description,
.meta-item strong,
.scope-note,
.license-text {
color: #cbd5e1;
}
.notice-card {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(23, 27, 34, 0.92);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.5);
}
.notice-meta {
border-color: #334155;
}
.scope-note {
background: rgba(0, 133, 150, 0.14);
}
.license-text {
border-color: #334155;
background: #111827;
}
}
@media (max-width: 640px) {
.notice-container {
padding: 20px 14px 24px;
}
.notice-heading {
padding: 42px 0 22px;
}
.notice-card-header {
align-items: flex-start;
flex-direction: column;
}
.meta-item {
min-width: 0;
width: 100%;
}
.license-text {
max-height: 500px;
padding: 15px;
font-size: 11px;
}
}
</style>
@@ -1,6 +1,15 @@
<template>
<div class="app-container report-page">
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
<el-form-item label="隧道编号" prop="tunnelCode">
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="隧道名称" prop="tunnelName">
<el-input v-model="queryParams.tunnelName" placeholder="请输入隧道名称" clearable maxlength="100" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="内部溯源号" prop="traceNo">
<el-input v-model="queryParams.traceNo" placeholder="完整溯源号,查询同组多托盘" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="客户ID" prop="customerId">
<el-input-number v-model="queryParams.customerId" :min="1" :controls="false" placeholder="请输入客户ID" />
</el-form-item>
@@ -8,7 +17,7 @@
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="请输入现场ID" />
</el-form-item>
<el-form-item label="产线ID" prop="lineId">
<el-input-number v-model="queryParams.lineId" :min="1" :controls="false" placeholder="请输入产线ID" />
<el-input-number v-model="queryParams.lineId" :min="1" :controls="false" placeholder="可选,仅筛选已登记产线" />
</el-form-item>
<el-form-item label="托盘编号" prop="palletCode">
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编号" clearable maxlength="64" @keyup.enter="handleQuery" />
@@ -36,7 +45,22 @@
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" class="report-table data-table" stripe border height="calc(100vh - 330px)" :data="reportList">
<template #empty><el-empty description="暂无产线消耗数据" /></template>
<el-table-column label="隧道编号" prop="tunnelCode" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelCode || '未登记' }}</template>
</el-table-column>
<el-table-column label="隧道名称" prop="tunnelName" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelName || '未登记' }}</template>
</el-table-column>
<el-table-column label="内部溯源号" prop="traceNo" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.traceNo || '未登记' }}</template>
</el-table-column>
<el-table-column label="生产日期" prop="productionDate" width="120">
<template #default="scope">{{ scope.row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="有效期至" prop="expiryDate" width="120">
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
</el-table-column>
<template #empty><el-empty description="暂无隧道消耗数据" /></template>
<el-table-column label="使用记录号" prop="usageNo" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="使用时间" prop="occurredAt" width="180">
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
@@ -48,7 +72,7 @@
<template #default="scope">{{ scope.row.siteName || `#${scope.row.siteId}` }}</template>
</el-table-column>
<el-table-column label="产线" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.lineName || `#${scope.row.lineId}` }}</template>
<template #default="scope">{{ scope.row.lineName || (scope.row.lineId ? `#${scope.row.lineId}` : '未登记') }}</template>
</el-table-column>
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true" />
<el-table-column label="菌种" min-width="150" :show-overflow-tooltip="true">
@@ -149,6 +173,9 @@ const queryParams = ref({
pageSize: 10,
beginTime: undefined,
endTime: undefined,
tunnelCode: undefined,
tunnelName: undefined,
traceNo: undefined,
customerId: undefined,
siteId: undefined,
lineId: undefined,
@@ -182,7 +209,7 @@ function getList() {
if (!isCurrentListRequest(listContext)) return
reportList.value = []
total.value = 0
loadError.value = '产线消耗报表加载失败,请稍后重试'
loadError.value = '隧道消耗报表加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentListRequest(listContext)) return
loading.value = false
@@ -1,6 +1,15 @@
<template>
<div class="app-container report-page">
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
<el-form-item label="隧道编号" prop="tunnelCode">
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="隧道名称" prop="tunnelName">
<el-input v-model="queryParams.tunnelName" placeholder="请输入隧道名称" clearable maxlength="100" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="内部溯源号" prop="traceNo">
<el-input v-model="queryParams.traceNo" placeholder="完整溯源号,查询同组多托盘" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="托盘编号" prop="palletCode">
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
@@ -48,6 +57,21 @@
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" class="report-table data-table" stripe border height="calc(100vh - 330px)" :data="reportList">
<el-table-column label="隧道编号" prop="tunnelCode" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelCode || '未登记' }}</template>
</el-table-column>
<el-table-column label="隧道名称" prop="tunnelName" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelName || '未登记' }}</template>
</el-table-column>
<el-table-column label="内部溯源号" prop="traceNo" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.traceNo || '未登记' }}</template>
</el-table-column>
<el-table-column label="生产日期" prop="productionDate" width="120">
<template #default="scope">{{ scope.row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="有效期至" prop="expiryDate" width="120">
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
</el-table-column>
<template #empty><el-empty description="暂无托盘库存数据" /></template>
<el-table-column label="托盘编号" prop="palletCode" min-width="140" :show-overflow-tooltip="true" />
<el-table-column label="状态" prop="flowState" min-width="150">
@@ -157,6 +181,9 @@ const queryParams = ref({
pageSize: 10,
beginTime: undefined,
endTime: undefined,
tunnelCode: undefined,
tunnelName: undefined,
traceNo: undefined,
palletCode: undefined,
flowState: undefined,
warehouseId: undefined,
@@ -169,6 +196,7 @@ const flowStateOptions = [
{ value: 'UNSTOCKED', label: '未入库' },
{ value: 'EMPTY_IN_WAREHOUSE', label: '仓库空托盘' },
{ value: 'FILLED_IN_WAREHOUSE', label: '仓库有料' },
{ value: 'IN_TRANSIT', label: '待收货' },
{ value: 'AT_SITE_UNUSED', label: '现场未使用' },
{ value: 'AT_SITE_PARTIAL', label: '现场部分使用' },
{ value: 'AT_SITE_EMPTY', label: '现场空托盘' },
@@ -185,7 +213,7 @@ function flowStateLabel(value) {
}
function currentLocation(row) {
return row.lineName || row.siteName || row.warehouseName ||
return row.tunnelName || row.tunnelCode || row.lineName || row.siteName || row.warehouseName ||
idFallback(row.lineId || row.siteId || row.warehouseId)
}
@@ -1,9 +1,21 @@
<template>
<div class="app-container report-page">
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
<el-form-item label="隧道编号" prop="tunnelCode">
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="隧道名称" prop="tunnelName">
<el-input v-model="queryParams.tunnelName" placeholder="请输入隧道名称" clearable maxlength="100" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="内部溯源号" prop="traceNo">
<el-input v-model="queryParams.traceNo" placeholder="完整溯源号,查询同组多托盘" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="托盘编号" prop="palletCode">
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="生产批次" prop="batchNo">
<el-input v-model="queryParams.batchNo" placeholder="请输入事件生产批次" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="事件类型" prop="eventType">
<el-select v-model="queryParams.eventType" placeholder="请选择事件类型" clearable>
<el-option v-for="option in eventTypeOptions" :key="option.value" :label="option.label" :value="option.value" />
@@ -25,7 +37,23 @@
</el-row>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-alert :title="traceSummary" description="按隧道或完整溯源号可查询跨托盘、跨批次明细。仅统计当前页,退回申请不重复计入确认退回量。" type="info" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" class="report-table data-table" stripe border height="calc(100vh - 330px)" :data="reportList">
<el-table-column label="隧道编号" prop="tunnelCode" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelCode || '未登记' }}</template>
</el-table-column>
<el-table-column label="隧道名称" prop="tunnelName" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelName || '未登记' }}</template>
</el-table-column>
<el-table-column label="内部溯源号" prop="traceNo" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.traceNo || '未登记' }}</template>
</el-table-column>
<el-table-column label="生产日期" prop="productionDate" width="120">
<template #default="scope">{{ scope.row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="有效期至" prop="expiryDate" width="120">
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
</el-table-column>
<template #empty><el-empty description="暂无托盘追溯数据" /></template>
<el-table-column label="发生时间" prop="occurredAt" width="180">
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
@@ -37,6 +65,17 @@
<template #default="scope">{{ sourceLabel(scope.row.sourceType, scope.row.sourceId) }}</template>
</el-table-column>
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true" />
<el-table-column label="生产批次快照" prop="batchNoSnapshot" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.batchNoSnapshot || '未登记' }}</template>
</el-table-column>
<el-table-column label="实际使用包数" prop="usedPackages" width="120" align="right" />
<el-table-column label="确认退回包数" prop="returnedPackages" width="120" align="right" />
<el-table-column label="变更前隧道" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ [scope.row.beforeTunnelCode, scope.row.beforeTunnelName].filter(Boolean).join(' / ') || '未登记' }}</template>
</el-table-column>
<el-table-column label="变更后隧道" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ [scope.row.afterTunnelCode, scope.row.afterTunnelName].filter(Boolean).join(' / ') || '未登记' }}</template>
</el-table-column>
<el-table-column label="变更前状态" prop="beforeState" min-width="140">
<template #default="scope">{{ flowStateLabel(scope.row.beforeState) }}</template>
</el-table-column>
@@ -140,6 +179,11 @@ import { listPalletTrace } from '@/api/report/palletTrace'
const { proxy } = getCurrentInstance()
const reportList = ref([])
const traceSummary = computed(() => {
const used = reportList.value.reduce((sum, row) => sum + (row.usedPackages ?? 0), 0)
const returned = reportList.value.reduce((sum, row) => sum + (row.returnedPackages ?? 0), 0)
return `本页实际使用 ${used} 包,确认退回 ${returned} 包`
})
const showSearch = ref(true)
const loading = ref(false)
const loadError = ref('')
@@ -150,13 +194,19 @@ const queryParams = ref({
pageSize: 10,
beginTime: undefined,
endTime: undefined,
tunnelCode: undefined,
tunnelName: undefined,
traceNo: undefined,
palletCode: undefined,
batchNo: undefined,
eventType: undefined
})
const eventTypeOptions = [
{ value: 'INBOUND_CONFIRMED', label: '入库确认' },
{ value: 'FILLING_CONFIRMED', label: '装填确认' },
{ value: 'SHIPMENT_CONFIRMED', label: '发货确认' },
{ value: 'SHIPMENT_RECEIVED', label: '收货确认' },
{ value: 'TUNNEL_ENTERED', label: '入隧登记' },
{ value: 'USAGE_RECORDED', label: '使用记录' },
{ value: 'RETURN_REQUESTED', label: '退回申请' },
{ value: 'RETURN_CONFIRMED', label: '退回确认' }
@@ -165,6 +215,7 @@ const sourceTypeOptions = [
{ value: 'INBOUND', label: '入库单' },
{ value: 'FILLING', label: '装填记录' },
{ value: 'SHIPMENT', label: '发货单' },
{ value: 'TUNNEL', label: '隧道' },
{ value: 'USAGE', label: '使用记录' },
{ value: 'RETURN', label: '退回单' }
]
@@ -172,6 +223,7 @@ const flowStateOptions = [
{ value: 'UNSTOCKED', label: '未入库' },
{ value: 'EMPTY_IN_WAREHOUSE', label: '仓库空托盘' },
{ value: 'FILLED_IN_WAREHOUSE', label: '仓库有料' },
{ value: 'IN_TRANSIT', label: '待收货' },
{ value: 'AT_SITE_UNUSED', label: '现场未使用' },
{ value: 'AT_SITE_PARTIAL', label: '现场部分使用' },
{ value: 'AT_SITE_EMPTY', label: '现场空托盘' },
@@ -1,9 +1,21 @@
<template>
<div class="app-container report-page">
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
<el-form-item label="隧道编号" prop="tunnelCode">
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="隧道名称" prop="tunnelName">
<el-input v-model="queryParams.tunnelName" placeholder="请输入隧道名称" clearable maxlength="100" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="内部溯源号" prop="traceNo">
<el-input v-model="queryParams.traceNo" placeholder="完整溯源号,查询同组多托盘" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="退回单号" prop="returnNo">
<el-input v-model="queryParams.returnNo" placeholder="请输入退回单号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="生产批次" prop="batchNo">
<el-input v-model="queryParams.batchNo" placeholder="请输入退回生产批次" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="来源现场ID" prop="sourceSiteId">
<el-input-number v-model="queryParams.sourceSiteId" :min="1" :controls="false" placeholder="请输入现场ID" />
</el-form-item>
@@ -36,6 +48,21 @@
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" class="report-table data-table" stripe border height="calc(100vh - 330px)" :data="reportList">
<el-table-column label="隧道编号" prop="tunnelCode" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelCode || '未登记' }}</template>
</el-table-column>
<el-table-column label="隧道名称" prop="tunnelName" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelName || '未登记' }}</template>
</el-table-column>
<el-table-column label="内部溯源号" prop="traceNo" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.traceNo || '未登记' }}</template>
</el-table-column>
<el-table-column label="生产日期" prop="productionDate" width="120">
<template #default="scope">{{ scope.row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="有效期至" prop="expiryDate" width="120">
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
</el-table-column>
<template #empty><el-empty description="暂无退回仓库数据" /></template>
<el-table-column label="退回单号" prop="returnNo" min-width="160" :show-overflow-tooltip="true" />
<el-table-column label="状态" prop="status" width="100">
@@ -54,6 +81,9 @@
<template #default="scope">{{ scope.row.targetWarehouseName || `#${scope.row.targetWarehouseId}` }}</template>
</el-table-column>
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true" />
<el-table-column label="退回生产批次" prop="batchNo" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.batchNoSnapshot || scope.row.batchNo || '未登记' }}</template>
</el-table-column>
<el-table-column label="剩余包数快照" prop="remainingPackagesSnapshot" width="130" align="right" />
</el-table>
@@ -147,7 +177,11 @@ const queryParams = ref({
pageSize: 10,
beginTime: undefined,
endTime: undefined,
tunnelCode: undefined,
tunnelName: undefined,
traceNo: undefined,
returnNo: undefined,
batchNo: undefined,
sourceSiteId: undefined,
targetWarehouseId: undefined,
palletCode: undefined,