front/src/views/operation/inboundScan/index.vue
2026-06-04 19:15:28 +08:00

543 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="waybill-container">
<div class="waybill-layout">
<!-- 表单区域 -->
<div class="form-section">
<div class="form-section-toolbar">
<a-checkbox v-model="keepScanFocus" size="small">
{{ $t('operation.common.keepScanFocus') }}
</a-checkbox>
</div>
<a-form :model="formData" layout="vertical" class="waybill-form">
<!-- 扫描单号 -->
<a-form-item :label="$t('operation.inbound.form.scanNumber')" class="form-item-large">
<a-input
ref="scanInputRef"
v-model="formData.waybillNumber"
:placeholder="$t('operation.inbound.form.placeholder.scanOrInput')"
size="large"
class="large-input"
@press-enter="() => void handleSubmit()"
/>
</a-form-item>
<!-- 收货实重、长宽高、销售产品、目的国家 同一行 -->
<div class="form-row-inline">
<div class="form-row-fields">
<div class="inline-field">
<label class="inline-label">{{ $t('operation.inbound.form.receivedWeight') }}</label>
<div class="input-with-unit">
<a-input
v-model="formData.weight"
:placeholder="$t('operation.inbound.form.placeholder.input')"
class="weight-input"
/>
<span class="unit-text">KG</span>
</div>
</div>
<div class="inline-field">
<label class="inline-label">{{ $t('operation.inbound.form.dimensions') }}</label>
<div class="dimension-inputs">
<a-input
v-model="formData.length"
:placeholder="$t('operation.inbound.form.placeholder.length')"
class="dimension-input"
/>
<span class="separator">×</span>
<a-input
v-model="formData.width"
:placeholder="$t('operation.inbound.form.placeholder.width')"
class="dimension-input"
/>
<span class="separator">×</span>
<a-input
v-model="formData.height"
:placeholder="$t('operation.inbound.form.placeholder.height')"
class="dimension-input"
/>
</div>
</div>
<div class="inline-field">
<label class="inline-label">{{ $t('operation.inbound.form.salesProduct') }}</label>
<a-select
v-model="formData.salesProduct"
:placeholder="$t('operation.inbound.form.placeholder.select')"
class="select-input-inline"
>
<a-option
v-for="product in salesProductOptions"
:key="product.value"
:value="product.value"
>
{{ product.label }}
</a-option>
</a-select>
</div>
<div class="inline-field">
<label class="inline-label">{{ $t('operation.inbound.form.destinationCountry') }}</label>
<a-select
v-model="formData.destinationCountry"
:placeholder="$t('operation.inbound.form.placeholder.select')"
class="select-input-inline"
>
<a-option
v-for="country in countryOptions"
:key="country.value"
:value="country.value"
>
{{ country.label }}
</a-option>
</a-select>
</div>
</div>
</div>
<!-- 操作按钮 -->
<a-form-item>
<div class="form-actions">
<a-button class="reset-btn" @click="handleReset">{{ $t('operation.inbound.actions.reset') }}</a-button>
<a-button type="primary" class="submit-btn" @click="handleSubmit">{{ $t('operation.inbound.actions.inbound') }}</a-button>
</div>
</a-form-item>
</a-form>
</div>
<!-- 最近操作的订单 -->
<div class="recent-orders-section">
<div class="list-toolbar">
<div class="toolbar-left">
</div>
<div class="toolbar-right">
<div class="pagination-controls">
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</div>
</div>
<div ref="tableContainerRef" class="list-wrap">
<HandsontablePro
ref="handsontableRef"
:columns="hotColumns"
:data="displayedData"
:total="totalCount"
:current-page="currentPage"
:page-size="pageSize"
:height="tableHeight"
:read-only="true"
:stretch-columns="true"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import { ref, reactive, computed, onMounted } from 'vue';
import { $baseMessage } from '@/utils';
import HandsontablePro from '@/components/HandsontablePro/index.vue';
import PaginationPro from '@/components/PaginationPro/index.vue';
import { useTableContainerHeight } from '@/composables/useTableContainerHeight';
import { useOperationScanFocus } from '@/composables/useOperationScanFocus';
import { inboundCreate, inboundRecentList } from '@/api/operation';
import { getSalesProductList } from '@/api/channel/salesProduct';
const { t } = useI18n();
const { tableContainerRef, tableHeight } = useTableContainerHeight();
const scanInputRef = ref<{ focus?: () => void } | null>(null);
const { keepScanFocus, maybeRefocusScan } = useOperationScanFocus('inboundScan', scanInputRef);
defineOptions({
name: 'InboundScan',
});
// 表单数据
const formData = reactive({
waybillNumber: '',
weight: '',
length: '',
width: '',
height: '',
salesProduct: undefined as number | undefined,
destinationCountry: undefined as string | undefined,
});
const salesProductOptions = ref<Array<{ label: string; value: number }>>([]);
const countryOptions = ref([
{ label: '美国', value: 'US' },
{ label: '英国', value: 'UK' },
{ label: '德国', value: 'DE' },
{ label: '法国', value: 'FR' },
]);
// HandsontablePro 列定义(序号由组件内部生成)
const hotColumns = computed(() => [
{ key: 'waybillNumber', label: t('operation.inbound.table.waybillNumber'), width: 180 },
{ key: 'weight', label: t('operation.inbound.table.weight'), width: 100 },
{ key: 'dimensions', label: t('operation.inbound.table.dimensions'), width: 150 },
{ key: 'product', label: t('operation.inbound.table.product'), width: 120 },
{ key: 'country', label: t('operation.inbound.table.country'), width: 120 },
{ key: 'warehouse', label: t('operation.inbound.table.warehouse'), width: 100 },
{ key: 'operateTime', label: t('operation.inbound.table.operateTime'), width: 180 },
{ key: 'operator', label: t('operation.inbound.table.operator'), width: 100 },
]);
const recentOrders = ref<any[]>([]);
const handsontableRef = ref<InstanceType<typeof HandsontablePro>>();
const currentPage = ref(1);
const pageSize = ref(50);
const totalCount = computed(() => recentOrders.value.length);
const displayedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
return recentOrders.value.slice(start, start + pageSize.value);
});
async function loadSalesProducts() {
try {
const { items } = await getSalesProductList({ page: 1, pageSize: 500, isActive: 1 });
salesProductOptions.value = (items || []).map((p: any) => ({
label: p.productName || p.productCode || String(p.id),
value: Number(p.id),
}));
} catch {
salesProductOptions.value = [];
}
}
async function loadRecent() {
try {
const { items } = await inboundRecentList({ limit: 200 });
recentOrders.value = (items || []).map((row: any) => ({
waybillNumber: row.waybillNo ?? row.waybillNumber ?? '',
weight: row.weight != null ? String(row.weight) : '-',
dimensions:
row.length != null && row.width != null && row.height != null
? `${row.length}×${row.width}×${row.height}`
: '-',
product: row.salesProductName ?? '-',
country: row.destinationCountry ?? '-',
warehouse: row.warehouseName ?? '-',
operateTime: row.operateTime ?? row.createTime ?? '',
operator: row.operatorName ?? row.operator ?? '-',
}));
} catch (e: any) {
$baseMessage(e?.message || '加载最近入库记录失败', 'error');
recentOrders.value = [];
}
}
function handlePageChange(page: number) {
currentPage.value = page;
}
function handlePageSizeChange(size: number) {
pageSize.value = size;
currentPage.value = 1;
}
async function handleSubmit() {
if (!formData.waybillNumber?.trim()) {
$baseMessage(t('operation.inbound.message.waybillNumberRequired'), 'warning');
maybeRefocusScan();
return;
}
try {
await inboundCreate({
waybillNo: formData.waybillNumber.trim(),
weight: formData.weight ? Number(formData.weight) : undefined,
length: formData.length ? Number(formData.length) : undefined,
width: formData.width ? Number(formData.width) : undefined,
height: formData.height ? Number(formData.height) : undefined,
salesProductId: formData.salesProduct,
destinationCountry: formData.destinationCountry,
});
$baseMessage(t('operation.inbound.message.inboundSuccess'), 'success');
handleReset();
await loadRecent();
maybeRefocusScan();
} catch (e: any) {
$baseMessage(e?.message || '入库失败', 'error');
maybeRefocusScan();
}
}
// 重置表单
function handleReset() {
formData.waybillNumber = '';
formData.weight = '';
formData.length = '';
formData.width = '';
formData.height = '';
formData.salesProduct = undefined;
formData.destinationCountry = undefined;
maybeRefocusScan();
}
onMounted(() => {
loadSalesProducts();
loadRecent();
});
</script>
<style lang="less" scoped>
.waybill-container {
padding: 24px;
background-color: #f5f5f5;
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.waybill-layout {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 16px;
flex: 1;
min-height: 0;
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-shrink: 0;
.header-left {
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #262626;
}
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
.sound-icon {
font-size: 18px;
color: #595959;
}
.broadcast-text {
font-size: 14px;
color: #595959;
}
}
}
.form-section {
background: #fff;
border-radius: 8px;
padding: 24px;
margin-bottom: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
flex: 3 1 0;
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
align-self: stretch;
}
.form-section-toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
margin: -4px 0 12px;
flex-shrink: 0;
}
.waybill-form {
.form-item-large {
:deep(.arco-form-item-label) {
font-size: 14px;
font-weight: 500;
color: #262626;
}
}
.large-input {
width: 100%;
max-width: 100%;
height: 48px;
font-size: 16px;
}
.input-with-unit {
display: flex;
align-items: center;
gap: 8px;
.weight-input {
flex: 1;
}
.unit-text {
font-size: 14px;
color: #595959;
min-width: 30px;
}
}
.form-row-inline {
margin-bottom: 16px;
}
.form-row-fields {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
.inline-field {
flex: 1 1 100%;
min-width: 0;
.inline-label {
display: block;
font-size: 14px;
font-weight: 500;
color: #262626;
margin-bottom: 8px;
}
}
.dimension-inputs {
display: flex;
align-items: center;
gap: 6px;
.dimension-input {
width: 100px;
min-width: 100px;
}
.separator {
font-size: 14px;
color: #595959;
padding: 0 2px;
}
}
.select-input-inline {
width: 100%;
min-width: 120px;
}
.form-actions {
display: flex;
gap: 16px;
margin-top: 8px;
.reset-btn {
min-width: 100px;
}
.submit-btn {
min-width: 100px;
}
}
}
.recent-orders-section {
background: #fff;
border-radius: 8px;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
flex: 7 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
.list-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px 0;
margin-bottom: 16px;
flex-shrink: 0;
flex-wrap: nowrap;
.toolbar-left .section-subtitle {
font-size: 12px;
color: #8c8c8c;
}
.toolbar-right .pagination-controls {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
flex-wrap: nowrap;
}
.toolbar-right .total-records,
.toolbar-right .go-to-text,
.toolbar-right .page-size-text,
.toolbar-right .page-text {
font-size: 12px;
color: #4e5969;
}
.toolbar-right .current-page {
font-size: 12px;
font-weight: 500;
color: #1d2129;
}
}
.list-wrap {
width: 100%;
flex: 1;
min-height: 0;
padding: 0 16px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
}
}
@media (max-width: 992px) {
.waybill-layout {
flex-direction: column;
overflow-y: auto;
}
.form-section {
flex: 0 0 auto;
max-width: none;
width: 100%;
min-width: 0;
}
.recent-orders-section {
flex: 1;
min-height: 360px;
}
}
</style>