fix: 子卷包装关系ui调整

This commit is contained in:
2026-08-18 11:27:46 +08:00
parent f22f832ad4
commit 68a7112921
6 changed files with 367 additions and 58 deletions

View File

@@ -505,7 +505,32 @@ export function useGridColumns(): VxeTableGridOptions<LmsSubPackageRelationApi.S
},
{
field: 'packageBoxSn',
title: '木箱唯一码',
title: '木箱码',
minWidth: 120,
},
{
field: 'containerName',
title: '子卷号',
minWidth: 120,
},
{
field: 'boxType',
title: '木箱料号',
minWidth: 120,
},
{
field: 'boxLength',
title: '木箱长度',
minWidth: 120,
},
{
field: 'boxWidth',
title: '木箱宽度',
minWidth: 120,
},
{
field: 'boxHigh',
title: '木箱高度',
minWidth: 120,
},
{
@@ -518,11 +543,6 @@ export function useGridColumns(): VxeTableGridOptions<LmsSubPackageRelationApi.S
title: '木箱自身重量',
minWidth: 120,
},
{
field: 'boxType',
title: '木箱料号',
minWidth: 120,
},
{
field: 'qualityGuaranPeriod',
title: '保质期',
@@ -558,11 +578,6 @@ export function useGridColumns(): VxeTableGridOptions<LmsSubPackageRelationApi.S
title: '入库日期',
minWidth: 120,
},
{
field: 'containerName',
title: '子卷号',
minWidth: 120,
},
{
field: 'width',
title: '产品规格(幅宽)',
@@ -648,21 +663,6 @@ export function useGridColumns(): VxeTableGridOptions<LmsSubPackageRelationApi.S
title: '交货单行号',
minWidth: 120,
},
{
field: 'boxLength',
title: '木箱长度',
minWidth: 120,
},
{
field: 'boxWidth',
title: '木箱宽度',
minWidth: 120,
},
{
field: 'boxHigh',
title: '木箱高度',
minWidth: 120,
},
{
field: 'saleOrderDescription',
title: '销售订单描述',

View File

@@ -11,6 +11,10 @@ describe('batch-form-model', () => {
it('creates an empty row with default status and a unique client key', () => {
const first = createBatchRow();
const second = createBatchRow();
const today = new Date();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const todayText = `${today.getFullYear()}-${month}-${day}`;
expect(first).toEqual({
clientKey: expect.any(String),
@@ -20,7 +24,7 @@ describe('batch-form-model', () => {
boxWidth: '',
boxHigh: '',
qualityGuaranPeriod: '',
dateOfFgInbound: '',
dateOfFgInbound: todayText,
containerName: '',
status: '0',
});
@@ -62,6 +66,7 @@ describe('batch-form-model', () => {
it('reports all required fields when values are empty or whitespace', () => {
const row = createBatchRow();
row.packageBoxSn = ' ';
row.dateOfFgInbound = ' ';
row.status = '\t';
expect(validateBatchRows([row]).get(0)).toEqual({
@@ -99,6 +104,26 @@ describe('batch-form-model', () => {
expect(validateBatchRows([row])).toEqual(new Map());
});
it('reports every row whose container name is duplicated', () => {
const first = Object.assign(createBatchRow(), {
packageBoxSn: 'BOX-001',
boxType: 'TYPE-A',
qualityGuaranPeriod: '90',
containerName: 'ROLL-001',
});
const second = Object.assign(createBatchRow(), {
packageBoxSn: 'BOX-002',
boxType: 'TYPE-A',
qualityGuaranPeriod: '90',
containerName: ' ROLL-001 ',
});
const errors = validateBatchRows([first, second]);
expect(errors.get(0)?.containerName).toBe('子卷号重复,与第 2 行相同');
expect(errors.get(1)?.containerName).toBe('子卷号重复,与第 1 行相同');
});
it('keeps failed rows in original order and maps server messages by client key', () => {
const rows = [createBatchRow(), createBatchRow(), createBatchRow()];
const result = applyBatchResult(rows, {

View File

@@ -18,6 +18,13 @@ function createClientKey() {
return `${Date.now()}-${fallbackSequence}`;
}
function createToday() {
const today = new Date();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${today.getFullYear()}-${month}-${day}`;
}
export function createBatchRow(): BatchFormRow {
return {
clientKey: createClientKey(),
@@ -27,7 +34,7 @@ export function createBatchRow(): BatchFormRow {
boxWidth: '',
boxHigh: '',
qualityGuaranPeriod: '',
dateOfFgInbound: '',
dateOfFgInbound: createToday(),
containerName: '',
status: '0',
};
@@ -52,6 +59,7 @@ export function validateBatchRows(
rows: BatchFormRow[],
): Map<number, BatchFieldErrors> {
const result = new Map<number, BatchFieldErrors>();
const containerNameRows = new Map<string, number[]>();
const requiredFields: [keyof BatchFormRow, string][] = [
['packageBoxSn', '木箱唯一码不能为空'],
['boxType', '木箱类型不能为空'],
@@ -71,8 +79,24 @@ export function validateBatchRows(
if (Object.keys(errors).length > 0) {
result.set(index, errors);
}
const containerName = row.containerName?.trim();
if (containerName) {
const indexes = containerNameRows.get(containerName) ?? [];
indexes.push(index);
containerNameRows.set(containerName, indexes);
}
});
for (const indexes of containerNameRows.values()) {
if (indexes.length < 2) continue;
indexes.forEach((index) => {
const duplicateIndex = indexes.find((item) => item !== index)!;
const errors = result.get(index) ?? {};
errors.containerName = `子卷号重复,与第 ${duplicateIndex + 1} 行相同`;
result.set(index, errors);
});
}
return result;
}

View File

@@ -8,12 +8,13 @@ import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import {
Alert,
Button,
Card,
FormItem,
DatePicker,
Input,
message,
Segmented,
@@ -138,7 +139,14 @@ const [Modal, modalApi] = useVbenModal({
fieldErrors.value = errors;
rowErrors.value = new Map();
if (errors.size > 0) {
message.warning('请检查必填项');
const hasDuplicateContainerName = [...errors.values()].some((item) =>
item.containerName?.startsWith('子卷号重复'),
);
message.warning(
hasDuplicateContainerName
? '子卷号不能重复,请检查后再提交'
: '请检查标红字段',
);
return;
}
@@ -182,8 +190,14 @@ const [Modal, modalApi] = useVbenModal({
<div class="batch-form">
<div class="toolbar">
<div class="toolbar-actions">
<Button type="primary" @click="appendRow">新增一行</Button>
<Button class="clear-button" @click="clearRows">清空全部</Button>
<Button type="primary" @click="appendRow">
<template #icon><IconifyIcon icon="ant-design:plus-outlined" /></template>
新增一行
</Button>
<Button class="clear-button" @click="clearRows">
<template #icon><IconifyIcon icon="ant-design:clear-outlined" /></template>
清空全部
</Button>
</div>
<Segmented
v-model:value="viewMode"
@@ -211,9 +225,9 @@ const [Modal, modalApi] = useVbenModal({
<table class="batch-table">
<thead>
<tr>
<th>序号</th><th><i>*</i> 木箱唯一码</th><th><i>*</i> 木箱类型</th>
<th>序号</th><th><i>*</i> 木箱唯一码</th><th><i>*</i> 子卷号</th><th><i>*</i> 木箱类型</th>
<th>木箱长</th><th>木箱宽</th><th>木箱高</th>
<th><i>*</i> 保质期</th><th><i>*</i> 入库日期</th><th><i>*</i> 子卷号</th>
<th><i>*</i> 保质期</th><th><i>*</i> 入库日期</th>
<th><i>*</i> 状态</th><th>操作</th>
</tr>
</thead>
@@ -222,15 +236,15 @@ const [Modal, modalApi] = useVbenModal({
<tr>
<td class="sequence">{{ index + 1 }}</td>
<td><Input v-model:value="row.packageBoxSn" :class="{ invalid: fieldError(index, 'packageBoxSn') }" placeholder="请输入木箱唯一码" @update:value="editField(index, 'packageBoxSn')" /><small>{{ fieldError(index, 'packageBoxSn') }}</small></td>
<td><Input v-model:value="row.containerName" :class="{ invalid: fieldError(index, 'containerName') }" placeholder="请输入子卷号" @update:value="editField(index, 'containerName')" /><small>{{ fieldError(index, 'containerName') }}</small></td>
<td><Input :value="row.boxType" readonly role="button" tabindex="0" aria-label="选择木箱类型" :class="{ invalid: fieldError(index, 'boxType') }" placeholder="请选择木箱类型" @click="selectBoxType(row.clientKey)" @keydown.enter="selectBoxType(row.clientKey)" @keydown.space.prevent="selectBoxType(row.clientKey)" /><small>{{ fieldError(index, 'boxType') }}</small></td>
<td><Input v-model:value="row.boxLength" readonly placeholder="自动带出" /></td>
<td><Input v-model:value="row.boxWidth" readonly placeholder="自动带出" /></td>
<td><Input v-model:value="row.boxHigh" readonly placeholder="自动带出" /></td>
<td><Input v-model:value="row.qualityGuaranPeriod" :class="{ invalid: fieldError(index, 'qualityGuaranPeriod') }" placeholder="请输入保质期" @update:value="editField(index, 'qualityGuaranPeriod')" /><small>{{ fieldError(index, 'qualityGuaranPeriod') }}</small></td>
<td><Input v-model:value="row.dateOfFgInbound" type="date" :class="{ invalid: fieldError(index, 'dateOfFgInbound') }" @update:value="editField(index, 'dateOfFgInbound')" /><small>{{ fieldError(index, 'dateOfFgInbound') }}</small></td>
<td><Input v-model:value="row.containerName" :class="{ invalid: fieldError(index, 'containerName') }" placeholder="请输入子卷号" @update:value="editField(index, 'containerName')" /><small>{{ fieldError(index, 'containerName') }}</small></td>
<td><Input v-model:value="row.qualityGuaranPeriod" type="number" :class="{ invalid: fieldError(index, 'qualityGuaranPeriod') }" placeholder="例如90" @update:value="editField(index, 'qualityGuaranPeriod')"><template #suffix><span class="input-unit"></span></template></Input><small>{{ fieldError(index, 'qualityGuaranPeriod') }}</small></td>
<td><DatePicker v-model:value="row.dateOfFgInbound" value-format="YYYY-MM-DD" format="YYYY-MM-DD" class="w-full" :class="{ invalid: fieldError(index, 'dateOfFgInbound') }" placeholder="请选择入库日期" @change="editField(index, 'dateOfFgInbound')" /><small>{{ fieldError(index, 'dateOfFgInbound') }}</small></td>
<td><Select v-model:value="row.status" :options="statusOptions" :class="{ invalid: fieldError(index, 'status') }" @change="editField(index, 'status')" /><small>{{ fieldError(index, 'status') }}</small></td>
<td class="operations"><Button type="link" @click="duplicateRow(index)">复制</Button><Button type="link" danger @click="removeRow(index)">删除</Button></td>
<td class="operations"><Button type="link" @click="duplicateRow(index)"><template #icon><IconifyIcon icon="ant-design:copy-outlined" /></template>复制</Button><Button type="link" danger @click="removeRow(index)"><template #icon><IconifyIcon icon="ant-design:delete-outlined" /></template>删除</Button></td>
</tr>
<tr v-if="rowErrors.get(row.clientKey)" class="row-error"><td colspan="11"> {{ index + 1 }} {{ rowErrors.get(row.clientKey) }}</td></tr>
</template>
@@ -239,21 +253,34 @@ const [Modal, modalApi] = useVbenModal({
</div>
<div v-else class="card-list">
<Card v-for="(row, index) in rows" :key="row.clientKey" size="small">
<template #title> {{ index + 1 }} 条包装关系</template>
<template #extra><Button type="link" @click="duplicateRow(index)">复制</Button><Button type="link" danger @click="removeRow(index)">删除</Button></template>
<Card v-for="(row, index) in rows" :key="row.clientKey">
<template #title><div class="record-title"><span class="record-number">{{ index + 1 }}</span>包装关系 {{ String(index + 1).padStart(2, '0') }}</div></template>
<template #extra><Button type="link" @click="duplicateRow(index)"><template #icon><IconifyIcon icon="ant-design:copy-outlined" /></template>复制</Button><Button type="link" danger @click="removeRow(index)"><template #icon><IconifyIcon icon="ant-design:delete-outlined" /></template>删除</Button></template>
<Alert v-if="rowErrors.get(row.clientKey)" type="error" show-icon :message="rowErrors.get(row.clientKey)" class="row-alert" />
<div class="card-grid">
<FormItem label="木箱唯一码" required :validate-status="fieldError(index, 'packageBoxSn') ? 'error' : undefined" :help="fieldError(index, 'packageBoxSn')"><Input v-model:value="row.packageBoxSn" @update:value="editField(index, 'packageBoxSn')" /></FormItem>
<FormItem label="木箱类型" required :validate-status="fieldError(index, 'boxType') ? 'error' : undefined" :help="fieldError(index, 'boxType')"><Input :value="row.boxType" readonly role="button" tabindex="0" aria-label="选择木箱类型" placeholder="请选择木箱类型" @click="selectBoxType(row.clientKey)" @keydown.enter="selectBoxType(row.clientKey)" @keydown.space.prevent="selectBoxType(row.clientKey)" /></FormItem>
<FormItem label="木箱长"><Input v-model:value="row.boxLength" readonly placeholder="自动带出" /></FormItem>
<FormItem label="木箱宽"><Input v-model:value="row.boxWidth" readonly placeholder="自动带出" /></FormItem>
<FormItem label="木箱高"><Input v-model:value="row.boxHigh" readonly placeholder="自动带出" /></FormItem>
<FormItem label="保质期" required :validate-status="fieldError(index, 'qualityGuaranPeriod') ? 'error' : undefined" :help="fieldError(index, 'qualityGuaranPeriod')"><Input v-model:value="row.qualityGuaranPeriod" @update:value="editField(index, 'qualityGuaranPeriod')" /></FormItem>
<FormItem label="入库日期" required :validate-status="fieldError(index, 'dateOfFgInbound') ? 'error' : undefined" :help="fieldError(index, 'dateOfFgInbound')"><Input v-model:value="row.dateOfFgInbound" type="date" @update:value="editField(index, 'dateOfFgInbound')" /></FormItem>
<FormItem label="子卷号" required :validate-status="fieldError(index, 'containerName') ? 'error' : undefined" :help="fieldError(index, 'containerName')"><Input v-model:value="row.containerName" @update:value="editField(index, 'containerName')" /></FormItem>
<FormItem label="状态" required :validate-status="fieldError(index, 'status') ? 'error' : undefined" :help="fieldError(index, 'status')"><Select v-model:value="row.status" :options="statusOptions" @change="editField(index, 'status')" /></FormItem>
</div>
<section class="card-section">
<div class="section-title"><i></i>识别信息</div>
<div class="section-fields identity-fields">
<div class="card-field" :class="{ 'has-error': fieldError(index, 'packageBoxSn') }"><label class="required">木箱唯一码</label><Input v-model:value="row.packageBoxSn" placeholder="扫描或输入木箱唯一码" @update:value="editField(index, 'packageBoxSn')" /><small>{{ fieldError(index, 'packageBoxSn') }}</small></div>
<div class="card-field" :class="{ 'has-error': fieldError(index, 'containerName') }"><label class="required">子卷号</label><Input v-model:value="row.containerName" placeholder="扫描或输入子卷号" @update:value="editField(index, 'containerName')" /><small>{{ fieldError(index, 'containerName') }}</small></div>
<div class="card-field" :class="{ 'has-error': fieldError(index, 'status') }"><label class="required">状态</label><Select v-model:value="row.status" class="w-full" :options="statusOptions" @change="editField(index, 'status')" /><small>{{ fieldError(index, 'status') }}</small></div>
</div>
</section>
<section class="card-section">
<div class="section-title"><i></i>木箱规格</div>
<div class="section-fields specification-fields">
<div class="card-field" :class="{ 'has-error': fieldError(index, 'boxType') }"><label class="required">木箱类型</label><Input :value="row.boxType" readonly role="button" tabindex="0" aria-label="选择木箱类型" placeholder="请选择木箱类型" @click="selectBoxType(row.clientKey)" @keydown.enter="selectBoxType(row.clientKey)" @keydown.space.prevent="selectBoxType(row.clientKey)" /><small>{{ fieldError(index, 'boxType') }}</small></div>
<div class="card-field"><label>木箱长</label><Input v-model:value="row.boxLength" readonly placeholder="自动带出"><template #suffix><span class="input-unit">mm</span></template></Input><small></small></div>
<div class="card-field"><label>木箱宽</label><Input v-model:value="row.boxWidth" readonly placeholder="自动带出"><template #suffix><span class="input-unit">mm</span></template></Input><small></small></div>
<div class="card-field"><label>木箱高</label><Input v-model:value="row.boxHigh" readonly placeholder="自动带出"><template #suffix><span class="input-unit">mm</span></template></Input><small></small></div>
</div>
</section>
<section class="card-section">
<div class="section-title"><i></i>时效信息</div>
<div class="section-fields lifecycle-fields">
<div class="card-field" :class="{ 'has-error': fieldError(index, 'qualityGuaranPeriod') }"><label class="required">保质期</label><Input v-model:value="row.qualityGuaranPeriod" type="number" placeholder="例如90" @update:value="editField(index, 'qualityGuaranPeriod')"><template #suffix><span class="input-unit"></span></template></Input><small>{{ fieldError(index, 'qualityGuaranPeriod') }}</small></div>
<div class="card-field" :class="{ 'has-error': fieldError(index, 'dateOfFgInbound') }"><label class="required">入库日期</label><DatePicker v-model:value="row.dateOfFgInbound" value-format="YYYY-MM-DD" format="YYYY-MM-DD" class="w-full" placeholder="请选择入库日期" @change="editField(index, 'dateOfFgInbound')" /><small>{{ fieldError(index, 'dateOfFgInbound') }}</small></div>
</div>
</section>
</Card>
</div>
</div>
@@ -377,9 +404,23 @@ const [Modal, modalApi] = useVbenModal({
}
.batch-table :deep(.ant-input),
.batch-table :deep(.ant-input-affix-wrapper),
.batch-table :deep(.ant-picker),
.batch-table :deep(.ant-select-selector) {
height: 36px;
min-height: 36px;
border-radius: 7px;
box-sizing: border-box;
}
.batch-table :deep(.ant-input-affix-wrapper) {
padding-top: 0;
padding-bottom: 0;
}
.batch-table :deep(.ant-input-affix-wrapper .ant-input) {
height: auto;
min-height: 0;
}
.batch-table :deep(.ant-input[readonly]) {
@@ -425,6 +466,10 @@ const [Modal, modalApi] = useVbenModal({
color: #ff4d4f;
}
.input-unit {
color: hsl(var(--muted-foreground));
}
.invalid {
border-color: #ff4d4f;
}
@@ -445,17 +490,147 @@ const [Modal, modalApi] = useVbenModal({
}
.card-list :deep(.ant-card) {
overflow: hidden;
border-radius: 10px;
box-shadow: 0 2px 8px rgb(23 32 51 / 4%);
}
.card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 24px;
.card-list :deep(.ant-card-head) {
min-height: 54px;
background: linear-gradient(90deg, #f8faff, #fff);
}
.card-grid :deep(.ant-input[readonly]) {
.card-list :deep(.ant-card-body) {
padding: 4px 24px 8px;
}
.record-title {
display: flex;
align-items: center;
gap: 12px;
color: #172033;
font-weight: 600;
}
.record-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
color: #fff;
font-size: 15px;
background: #326cff;
border-radius: 10px;
}
.card-section {
display: grid;
padding: 18px 0 4px;
grid-template-columns: 170px minmax(0, 1fr);
gap: 18px;
}
.card-section + .card-section {
border-top: 1px dashed #e3e7ef;
}
.section-title {
display: flex;
align-items: flex-start;
gap: 10px;
padding-top: 8px;
color: #576071;
font-size: 15px;
font-weight: 600;
}
.section-title i {
width: 8px;
height: 8px;
margin-top: 7px;
background: #84a9ff;
border-radius: 50%;
}
.section-fields {
display: grid;
gap: 18px;
}
.identity-fields {
grid-template-columns: 1.35fr 1.35fr 0.9fr;
}
.specification-fields {
grid-template-columns: 1.35fr repeat(3, 0.9fr);
}
.lifecycle-fields {
max-width: 75%;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card-field {
display: flex;
min-width: 0;
flex-direction: column;
}
.card-field label {
height: 24px;
margin-bottom: 7px;
color: #596275;
line-height: 24px;
}
.card-field label.required::before {
margin-right: 4px;
color: #ff4d4f;
content: '*';
}
.card-field small {
display: block;
height: 20px;
padding-top: 3px;
color: #ff4d4f;
line-height: 17px;
}
.section-fields :deep(.ant-input),
.section-fields :deep(.ant-input-affix-wrapper),
.section-fields :deep(.ant-picker),
.section-fields :deep(.ant-select-single .ant-select-selector) {
height: 40px;
min-height: 40px;
border-radius: 8px;
box-sizing: border-box;
}
.section-fields :deep(.ant-input-affix-wrapper) {
padding-top: 0;
padding-bottom: 0;
}
.section-fields :deep(.ant-input-affix-wrapper .ant-input) {
height: auto;
min-height: 0;
}
.section-fields :deep(.ant-select-single .ant-select-selection-item),
.section-fields :deep(.ant-select-single .ant-select-selection-placeholder) {
line-height: 38px;
}
.card-field.has-error :deep(.ant-input),
.card-field.has-error :deep(.ant-input-affix-wrapper),
.card-field.has-error :deep(.ant-picker),
.card-field.has-error :deep(.ant-select-selector) {
border-color: #ff4d4f;
}
.section-fields :deep(.ant-input[readonly]) {
color: #667085;
background: #f2f4f7;
}
@@ -474,7 +649,14 @@ const [Modal, modalApi] = useVbenModal({
text-align: left;
}
.card-grid {
.card-section {
grid-template-columns: 1fr;
}
.identity-fields,
.specification-fields,
.lifecycle-fields {
max-width: none;
grid-template-columns: 1fr;
}
}