front/src/views/order/priceCheck/index.vue
2026-03-23 10:00:44 +08:00

491 lines
15 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.

<script lang="ts" setup>
import { ref, reactive, computed } from 'vue';
import { IconSearch, IconRefresh } from '@arco-design/web-vue/es/icon';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import HandsontablePro from '@/components/HandsontablePro/index.vue';
import PaginationPro from '@/components/PaginationPro/index.vue';
import { useTableContainerHeight } from '@/composables/useTableContainerHeight';
import { mockPriceResults } from './mockPriceResults';
defineOptions({
name: 'PriceCheck',
});
const { t } = useI18n();
const { tableContainerRef, tableHeight } = useTableContainerHeight();
const router = useRouter();
const formData = reactive({
destinationCountry: '',
shippingMethod: '',
cargoWeight: '',
length: '',
width: '',
height: '',
cargoType: '普货',
destinationProvince: '',
postalCode: '',
});
const processQueryResults = (results: any[]) => {
const parseDeliveryTime = (timeStr: string) => {
const match = timeStr.match(/(\d+)-(\d+)/);
return match ? parseInt(match[2]) : 0;
};
const parseTotalCost = (cost: string | number) => {
if (typeof cost === 'number') return cost;
const num = parseFloat(cost.toString().replace(/[^\d.]/g, ''));
return isNaN(num) ? Infinity : num;
};
const processed = results.map((item) => ({
...item,
maxDeliveryDays: parseDeliveryTime(item.deliveryTime || '0-0'),
totalCostNum: parseTotalCost(item.totalCost),
}));
const minCost = Math.min(...processed.map((item) => item.totalCostNum));
const minDays = Math.min(...processed.map((item) => item.maxDeliveryDays));
processed.forEach((item) => {
item.isPriceOptimal = item.totalCostNum === minCost;
item.isTimeOptimal = item.maxDeliveryDays === minDays;
});
return processed.sort((a, b) => {
if (a.isPriceOptimal && !b.isPriceOptimal) return -1;
if (!a.isPriceOptimal && b.isPriceOptimal) return 1;
if (a.isTimeOptimal && !b.isTimeOptimal) return -1;
if (!a.isTimeOptimal && b.isTimeOptimal) return 1;
return 0;
});
};
const queryResults = ref(processQueryResults([...mockPriceResults]));
const currentPage = ref(1);
const pageSize = ref(30);
const totalCount = computed(() => queryResults.value.length);
const processedTableData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
return queryResults.value.slice(start, start + pageSize.value);
});
const hotColumns = computed(() => [
{ key: 'shippingMethod', label: t('order.priceCheck.table.shippingMethod'), width: 220 },
{ key: 'warehouse', label: t('order.priceCheck.table.warehouse'), width: 120 },
{ key: 'deliveryTime', label: t('order.priceCheck.table.deliveryTime'), width: 100 },
{ key: 'volumetricWeight', label: t('order.priceCheck.table.volumetricWeight'), width: 100 },
{ key: 'chargeableWeight', label: t('order.priceCheck.table.chargeableWeight'), width: 100 },
{ key: 'costFormula', label: t('order.priceCheck.table.costFormula'), width: 150 },
{ key: 'freight', label: t('order.priceCheck.table.freight'), width: 100 },
{ key: 'otherFees', label: t('order.priceCheck.table.otherFees'), width: 120 },
{ key: 'totalCost', label: t('order.priceCheck.table.totalCost'), width: 120 },
]);
/** 序号列占 col 0,数据列从 col 1 起(操作列在末尾,由 HandsontablePro 处理) */
const customCellRenderer = (row: number, col: number, value: any, rowData: any): string | null => {
const dataColIndex = col - 1;
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null;
const colKey = hotColumns.value[dataColIndex].key;
if (colKey === 'shippingMethod') {
let text = rowData?.shippingMethod || '';
const tags: string[] = [];
if (rowData?.isPriceOptimal) {
tags.push(
'<span style="display:inline-block;padding:1px 6px;margin-left:4px;border-radius:2px;font-size:11px;color:#00b42a;background-color:#e8f8e8;">价格最优</span>'
);
}
if (rowData?.isTimeOptimal) {
tags.push(
'<span style="display:inline-block;padding:1px 6px;margin-left:4px;border-radius:2px;font-size:11px;color:#165dff;background-color:#e8f3ff;">时效最优</span>'
);
}
return tags.length > 0 ? text + tags.join('') : text;
}
if (colKey === 'freight') {
return value != null ? `<span style="color:#595959;font-weight:500;">¥${value}</span>` : null;
}
if (colKey === 'otherFees') {
if (value === '-') return `<span style="color:#bfbfbf;">${value}</span>`;
return value != null ? `<span class="other-fees-cell">¥${value}</span>` : null;
}
if (colKey === 'totalCost') {
return value != null ? `<span class="total-cost-cell">¥${value}</span>` : null;
}
return null;
};
const actionRenderer = (row: any, rowIndex: number): string => {
return `<a href="javascript:void(0)" class="order-action-link" data-action="order" data-row-index="${rowIndex}">${t('order.priceCheck.table.orderNow')}</a>`;
};
const countryOptions = ref([
{ labelKey: 'order.priceCheck.country.US', value: 'US' },
{ labelKey: 'order.priceCheck.country.UK', value: 'UK' },
{ labelKey: 'order.priceCheck.country.DE', value: 'DE' },
{ labelKey: 'order.priceCheck.country.FR', value: 'FR' },
]);
const shippingMethodOptions = ref([
{ labelKey: 'order.priceCheck.shippingMethod.air', value: 'air' },
{ labelKey: 'order.priceCheck.shippingMethod.sea', value: 'sea' },
{ labelKey: 'order.priceCheck.shippingMethod.land', value: 'land' },
]);
const cargoTypeOptions = ref([
{ labelKey: 'order.priceCheck.cargoType.general', value: '普货' },
{ labelKey: 'order.priceCheck.cargoType.sensitive', value: '敏感货' },
{ labelKey: 'order.priceCheck.cargoType.dangerous', value: '危险品' },
]);
const provinceOptions = ref([
{ labelKey: 'order.priceCheck.province.CA', value: 'CA' },
{ labelKey: 'order.priceCheck.province.NY', value: 'NY' },
{ labelKey: 'order.priceCheck.province.TX', value: 'TX' },
]);
const handleQuery = () => {
// TODO: 替换为接口结果后:queryResults.value = processQueryResults(res.items)
};
const handleReset = () => {
Object.assign(formData, {
destinationCountry: '',
shippingMethod: '',
cargoWeight: '',
length: '',
width: '',
height: '',
cargoType: '普货',
destinationProvince: '',
postalCode: '',
});
};
const handleOrder = (item: any) => {
router.push('/order/create');
};
const handleActionClick = (row: any) => {
handleOrder(row);
};
const handlePageChange = (page: number) => {
currentPage.value = page;
};
const handlePageSizeChange = (size: number) => {
pageSize.value = size;
currentPage.value = 1;
};
</script>
<template>
<div class="price-check-container">
<div class="query-form-section">
<div class="form-container">
<div class="form-row">
<div class="form-item">
<label class="form-label required">{{ $t('order.priceCheck.form.destinationCountry') }}</label>
<a-select
v-model="formData.destinationCountry"
:placeholder="$t('order.priceCheck.form.destinationCountry.placeholder')"
class="form-select"
>
<a-option v-for="option in countryOptions" :key="option.value" :value="option.value">
{{ $t(option.labelKey) }}
</a-option>
</a-select>
</div>
<div class="form-item">
<label class="form-label required">{{ $t('order.priceCheck.form.shippingMethod') }}</label>
<a-select
v-model="formData.shippingMethod"
:placeholder="$t('order.priceCheck.form.shippingMethod.placeholder')"
class="form-select"
>
<a-option v-for="option in shippingMethodOptions" :key="option.value" :value="option.value">
{{ $t(option.labelKey) }}
</a-option>
</a-select>
</div>
<div class="form-item">
<label class="form-label required">{{ $t('order.priceCheck.form.cargoWeight') }}</label>
<a-input
v-model="formData.cargoWeight"
:placeholder="$t('order.priceCheck.form.cargoWeight.placeholder')"
class="form-input"
/>
</div>
<div class="form-item dimensions-group">
<label class="form-label">{{ $t('order.priceCheck.form.dimensions') }}</label>
<div class="dimensions-inputs">
<a-input
v-model="formData.length"
:placeholder="$t('order.priceCheck.form.dimensions.length')"
class="dimension-input"
/>
<a-input
v-model="formData.width"
:placeholder="$t('order.priceCheck.form.dimensions.width')"
class="dimension-input"
/>
<a-input
v-model="formData.height"
:placeholder="$t('order.priceCheck.form.dimensions.height')"
class="dimension-input"
/>
</div>
</div>
</div>
<div class="form-row">
<div class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.cargoType') }}</label>
<a-select v-model="formData.cargoType" class="form-select">
<a-option v-for="option in cargoTypeOptions" :key="option.value" :value="option.value">
{{ $t(option.labelKey) }}
</a-option>
</a-select>
</div>
<div class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.destinationProvince') }}</label>
<a-select
v-model="formData.destinationProvince"
:placeholder="$t('order.priceCheck.form.destinationProvince.placeholder')"
class="form-select"
>
<a-option v-for="option in provinceOptions" :key="option.value" :value="option.value">
{{ $t(option.labelKey) }}
</a-option>
</a-select>
</div>
<div class="form-item">
<label class="form-label">{{ $t('order.priceCheck.form.postalCode') }}</label>
<a-input
v-model="formData.postalCode"
:placeholder="$t('order.priceCheck.form.postalCode.placeholder')"
class="form-input"
/>
</div>
<div class="form-item button-group">
<a-button type="primary" class="query-btn" @click="handleQuery">
<template #icon>
<icon-search />
</template>
{{ $t('order.priceCheck.actions.query') }}
</a-button>
<a-button class="reset-btn" @click="handleReset">
<template #icon>
<icon-refresh />
</template>
{{ $t('order.priceCheck.actions.reset') }}
</a-button>
</div>
</div>
</div>
</div>
<div class="table-section">
<div class="table-toolbar">
<div class="toolbar-left" />
<div class="toolbar-right">
<div class="pagination-controls">
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</div>
</div>
<div ref="tableContainerRef" class="list-wrap">
<HandsontablePro
:columns="hotColumns"
:data="processedTableData"
:total="totalCount"
:current-page="currentPage"
:page-size="pageSize"
:show-action-column="true"
action-column-position="end"
:action-column-width="120"
:action-renderer="actionRenderer"
:custom-cell-renderer="customCellRenderer"
:height="tableHeight"
:stretch-columns="true"
@action-click="handleActionClick"
/>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.price-check-container {
padding: 16px;
background-color: #f0f3f4;
min-height: 100vh;
height: auto;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.query-form-section {
background: #fff;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.form-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-row {
display: flex;
gap: 20px;
align-items: flex-end;
flex-wrap: wrap;
}
.form-item {
display: flex;
flex-direction: column;
gap: 8px;
flex: 0 0 200px;
&.dimensions-group {
flex: 0 0 300px;
}
&.button-group {
flex: 0 0 300px;
flex-direction: row;
gap: 8px;
align-items: flex-end;
}
}
.form-label {
font-size: 14px;
font-weight: 500;
color: #262626;
&.required::after {
content: '*';
color: #f53f3f;
margin-left: 4px;
}
}
.form-select,
.form-input {
width: 100%;
}
.dimensions-inputs {
display: flex;
gap: 8px;
.dimension-input {
flex: 1;
}
}
.query-btn,
.reset-btn {
height: 32px;
border-radius: 4px;
}
.reset-btn {
border-color: #d9d9d9;
color: #262626;
&:hover {
border-color: var(--app-primary-color);
color: var(--app-primary-color);
}
}
.table-section {
background: #fff;
border-radius: 8px;
padding: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
flex: 0 0 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.table-toolbar {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.list-wrap {
width: 100%;
flex: 0 0 auto;
height: 900px;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
}
:deep(.order-action-link) {
color: var(--app-primary-color);
font-weight: 500;
text-decoration: none;
padding: 4px 12px;
border-radius: 4px;
display: inline-block;
&:hover {
opacity: 0.85;
}
}
:deep(.total-cost-cell) {
color: #f53f3f;
font-weight: 600;
font-size: 15px;
}
:deep(.other-fees-cell) {
color: var(--app-primary-color);
font-weight: 500;
}
@media (max-width: 1200px) {
.form-item {
flex: 1 1 calc(50% - 10px);
min-width: 180px;
}
}
</style>