fix: 子卷包装关系手动添加

This commit is contained in:
2026-08-18 09:48:50 +08:00
parent 98f573fab4
commit f22f832ad4
6 changed files with 1493 additions and 16 deletions

View File

@@ -19,6 +19,7 @@ import {
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import BatchForm from './modules/batch-form.vue';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
@@ -26,6 +27,11 @@ const [FormModal, formModalApi] = useVbenModal({
destroyOnClose: true,
});
const [BatchFormModal, batchFormModalApi] = useVbenModal({
connectedComponent: BatchForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
@@ -36,6 +42,11 @@ function handleCreate() {
formModalApi.setData(null).open();
}
/** 批量创建子卷包装关系 */
function handleBatchCreate() {
batchFormModalApi.open();
}
/** 编辑子卷包装关系 */
function handleEdit(row: LmsSubPackageRelationApi.SubPackageRelation) {
formModalApi.setData(row).open();
@@ -127,6 +138,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<BatchFormModal @success="handleRefresh" />
<Grid table-title="子卷包装关系列表">
<template #toolbar-tools>
<TableAction
@@ -138,6 +150,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
auth: ['lms:sub-package-relation:create'],
onClick: handleCreate,
},
{
label: '批量新增',
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['lms:sub-package-relation:create'],
onClick: handleBatchCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',

View File

@@ -121,12 +121,9 @@ describe('batch-form-model', () => {
});
expect(result.rows).toEqual([rows[0], rows[2]]);
expect(result.rowErrors).toEqual(
new Map([
[rows[2]!.clientKey, '第三行失败'],
[rows[0]!.clientKey, '第一行失败'],
]),
);
expect(result.safe).toBe(true);
expect(result.rowErrors.get(rows[0]!.clientKey)).toBe('第一行失败');
expect(result.rowErrors.get(rows[2]!.clientKey)).toBe('第三行失败');
});
it('falls back to one-based row index when the failure client key is unknown', () => {
@@ -145,6 +142,7 @@ describe('batch-form-model', () => {
});
expect(result.rows).toEqual([rows[1]]);
expect(result.safe).toBe(true);
expect(result.rowErrors).toEqual(
new Map([[rows[1]!.clientKey, '第二行失败']]),
);
@@ -167,6 +165,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -189,6 +188,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -212,6 +212,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -228,6 +229,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -249,6 +251,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -277,6 +280,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -299,6 +303,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -321,6 +326,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -343,6 +349,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -355,6 +362,6 @@ describe('batch-form-model', () => {
failures: [],
});
expect(result).toEqual({ rows: [], rowErrors: new Map() });
expect(result).toEqual({ rows: [], rowErrors: new Map(), safe: true });
});
});

View File

@@ -79,11 +79,16 @@ export function validateBatchRows(
export function applyBatchResult(
rows: BatchFormRow[],
result: LmsSubPackageRelationApi.BatchCreateResult,
): { rows: BatchFormRow[]; rowErrors: Map<string, string> } {
): {
rowErrors: Map<string, string>;
rows: BatchFormRow[];
safe: boolean;
} {
const generalError = '批量保存结果无法匹配,请核对后重试';
const unsafeResult = () => ({
rows,
rowErrors: new Map(rows.map((item) => [item.clientKey, generalError])),
safe: false,
});
const rowByClientKey = new Map<string, BatchFormRow>();
for (const row of rows) {
@@ -105,7 +110,7 @@ export function applyBatchResult(
}
if (result.failures.length === 0) {
return { rows: [], rowErrors: new Map() };
return { rows: [], rowErrors: new Map(), safe: true };
}
const failedClientKeys = new Set<string>();
@@ -142,5 +147,6 @@ export function applyBatchResult(
return {
rows: rows.filter((row) => failedClientKeys.has(row.clientKey)),
rowErrors,
safe: true,
};
}

View File

@@ -0,0 +1,481 @@
<script lang="ts" setup>
import type { BatchFieldErrors, BatchFormRow } from './batch-form-model';
import type { LmsBoxTypeApi } from '#/api/lms/boxtype';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import {
Alert,
Button,
Card,
FormItem,
Input,
message,
Segmented,
Select,
} from 'antdv-next';
import { batchCreateSubPackageRelation } from '#/api/lms/subpackagerelation';
import BoxTypeSelectModalComponent from '#/views/lms/boxtype/components/BoxTypeSelectModal.vue';
import {
applyBatchResult,
copyBatchRow,
createBatchRow,
validateBatchRows,
} from './batch-form-model';
const emit = defineEmits<{ success: [] }>();
const rows = ref<BatchFormRow[]>([createBatchRow()]);
const viewMode = ref<'card' | 'table'>('table');
const activeClientKey = ref<string>();
const fieldErrors = ref(new Map<number, BatchFieldErrors>());
const rowErrors = ref(new Map<string, string>());
const resultUncertain = ref(false);
const viewOptions = [
{ label: '▤ 卡片', value: 'card' },
{ label: '▦ 表格', value: 'table' },
];
const statusOptions = computed(() =>
getDictOptions(DICT_TYPE.SUB_PACKAGE_STATUS).map((item) => ({
...item,
value: String(item.value),
})),
);
function reset() {
rows.value = [createBatchRow()];
viewMode.value = 'table';
activeClientKey.value = undefined;
fieldErrors.value = new Map();
rowErrors.value = new Map();
resultUncertain.value = false;
}
function clearErrors() {
fieldErrors.value = new Map();
}
function appendRow() {
rows.value.push(createBatchRow());
clearErrors();
}
function duplicateRow(index: number) {
rows.value.splice(index + 1, 0, copyBatchRow(rows.value[index]!));
clearErrors();
}
function removeRow(index: number) {
const [removed] = rows.value.splice(index, 1);
if (rows.value.length === 0) rows.value.push(createBatchRow());
fieldErrors.value = new Map();
if (removed) rowErrors.value.delete(removed.clientKey);
}
function clearRows() {
rows.value = [createBatchRow()];
activeClientKey.value = undefined;
fieldErrors.value = new Map();
rowErrors.value = new Map();
}
function editField(index: number, field: keyof BatchFormRow) {
const errors = fieldErrors.value.get(index);
if (errors?.[field]) {
const next = { ...errors };
delete next[field];
const map = new Map(fieldErrors.value);
Object.keys(next).length ? map.set(index, next) : map.delete(index);
fieldErrors.value = map;
}
rowErrors.value.delete(rows.value[index]!.clientKey);
}
function fieldError(index: number, field: keyof BatchFormRow) {
return fieldErrors.value.get(index)?.[field];
}
const [BoxTypeSelectModal, boxTypeSelectModalApi] = useVbenModal({
connectedComponent: BoxTypeSelectModalComponent,
destroyOnClose: true,
});
function selectBoxType(clientKey: string) {
activeClientKey.value = clientKey;
boxTypeSelectModalApi.setData({ multiple: false }).open();
}
function handleBoxTypeSelect(selectedRows: LmsBoxTypeApi.BoxType[]) {
const index = rows.value.findIndex(
(item) => item.clientKey === activeClientKey.value,
);
const selected = selectedRows[0];
if (index < 0 || !selected) return;
const row = rows.value[index]!;
row.boxType = selected.boxType ?? '';
row.boxLength = selected.boxLength == null ? '' : String(selected.boxLength);
row.boxWidth = selected.boxWidth == null ? '' : String(selected.boxWidth);
row.boxHigh = selected.boxHigh == null ? '' : String(selected.boxHigh);
editField(index, 'boxType');
activeClientKey.value = undefined;
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
if (resultUncertain.value) {
message.error('上次保存结果无法确认,请刷新列表核对后关闭弹窗');
return;
}
const errors = validateBatchRows(rows.value);
fieldErrors.value = errors;
rowErrors.value = new Map();
if (errors.size > 0) {
message.warning('请检查必填项');
return;
}
modalApi.lock();
try {
const result = await batchCreateSubPackageRelation(rows.value);
const applied = applyBatchResult(rows.value, result);
if (result.successCount > 0) emit('success');
if (!applied.safe) {
rows.value = applied.rows;
rowErrors.value = applied.rowErrors;
fieldErrors.value = new Map();
resultUncertain.value = true;
message.error('保存结果无法确认,请刷新列表核对后关闭弹窗');
return;
}
if (result.failureCount === 0) {
await modalApi.close();
message.success(`成功新增 ${result.successCount} 条子卷包装关系`);
return;
}
rows.value = applied.rows;
rowErrors.value = applied.rowErrors;
fieldErrors.value = new Map();
message.warning(
`成功 ${result.successCount} 条,失败 ${result.failureCount}`,
);
} finally {
modalApi.unlock();
}
},
onOpenChange(isOpen) {
if (isOpen) reset();
},
});
</script>
<template>
<Modal title="自由切换批量新增" class="w-[1400px] max-w-[96vw]">
<BoxTypeSelectModal @select="handleBoxTypeSelect" />
<div class="batch-form">
<div class="toolbar">
<div class="toolbar-actions">
<Button type="primary" @click="appendRow">新增一行</Button>
<Button class="clear-button" @click="clearRows">清空全部</Button>
</div>
<Segmented
v-model:value="viewMode"
class="view-switch"
:options="viewOptions"
aria-label="录入视图"
/>
<span class="record-counter">
当前共 <strong>{{ rows.length }}</strong> 条记录
</span>
</div>
<Alert class="view-tip" type="info" show-icon>
<template #message>
{{
viewMode === 'table'
? '表格视图适合批量核对,可横向滚动查看全部字段。'
: '卡片视图适合逐条录入和检查,切换后数据自动保留。'
}}
</template>
<template #action>尺寸单位mm</template>
</Alert>
<div v-if="viewMode === 'table'" class="table-wrap">
<table class="batch-table">
<thead>
<tr>
<th>序号</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>操作</th>
</tr>
</thead>
<tbody>
<template v-for="(row, index) in rows" :key="row.clientKey">
<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 :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><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>
</tr>
<tr v-if="rowErrors.get(row.clientKey)" class="row-error"><td colspan="11"> {{ index + 1 }} {{ rowErrors.get(row.clientKey) }}</td></tr>
</template>
</tbody>
</table>
</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>
<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>
</Card>
</div>
</div>
</Modal>
</template>
<style scoped>
.batch-form {
display: flex;
min-height: min(650px, calc(100vh - 230px));
padding: 0 20px 18px;
background: #f7f9fc;
flex-direction: column;
}
.toolbar {
display: grid;
align-items: center;
padding: 14px 6px;
margin: 0 -6px 18px;
background: hsl(var(--background));
grid-template-columns: 1fr auto 1fr;
gap: 16px;
}
.toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.toolbar-actions :deep(.ant-btn) {
height: 36px;
padding: 0 18px;
border-radius: 8px;
}
.clear-button {
color: #1677ff;
background: #eef4ff;
border-color: #cfe0ff;
}
.view-switch {
padding: 3px;
border-radius: 8px;
}
.view-switch :deep(.ant-segmented-item) {
min-width: 82px;
border-radius: 6px;
}
.view-switch :deep(.ant-segmented-item-selected) {
color: #1677ff;
font-weight: 600;
}
.record-counter {
color: hsl(var(--muted-foreground));
text-align: right;
}
.record-counter strong {
margin: 0 4px;
color: #1677ff;
font-size: 18px;
}
.view-tip {
margin-bottom: 16px;
border-color: #cfe0ff;
border-radius: 9px;
}
.table-wrap {
flex: 1;
min-height: 420px;
overflow: auto;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 10px;
}
.batch-table {
width: 100%;
min-width: 1780px;
border-collapse: separate;
border-spacing: 0;
}
.batch-table th,
.batch-table td {
padding: 10px;
vertical-align: top;
background: hsl(var(--background));
border-right: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--border));
}
.batch-table th {
position: sticky;
top: 0;
z-index: 3;
height: 48px;
color: #4b5870;
font-weight: 600;
text-align: left;
white-space: nowrap;
background: #f3f6fb;
vertical-align: middle;
}
.batch-table th i {
color: #ff4d4f;
font-style: normal;
}
.batch-table td {
min-width: 145px;
}
.batch-table :deep(.ant-input),
.batch-table :deep(.ant-select-selector) {
min-height: 36px;
border-radius: 7px;
}
.batch-table :deep(.ant-input[readonly]) {
color: #667085;
background: #f2f4f7;
}
.batch-table .sequence {
position: sticky;
left: 0;
z-index: 2;
min-width: 64px;
color: #788196;
text-align: center;
}
.batch-table th:first-child {
left: 0;
z-index: 5;
min-width: 64px;
text-align: center;
}
.batch-table th:last-child,
.batch-table .operations {
position: sticky;
right: 0;
z-index: 2;
min-width: 128px;
text-align: center;
white-space: nowrap;
box-shadow: -5px 0 12px rgb(21 35 62 / 6%);
}
.batch-table th:last-child {
z-index: 5;
}
.batch-table small {
display: block;
min-height: 18px;
margin-top: 2px;
color: #ff4d4f;
}
.invalid {
border-color: #ff4d4f;
}
.row-error td {
color: #ff4d4f;
background: #fff2f0;
}
.card-list {
display: grid;
flex: 1;
align-content: start;
gap: 16px;
min-height: 420px;
padding: 2px;
overflow: auto;
}
.card-list :deep(.ant-card) {
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-grid :deep(.ant-input[readonly]) {
color: #667085;
background: #f2f4f7;
}
.row-alert {
margin-bottom: 16px;
}
@media (max-width: 768px) {
.toolbar {
align-items: flex-start;
grid-template-columns: 1fr;
}
.record-counter {
text-align: left;
}
.card-grid {
grid-template-columns: 1fr;
}
}
</style>