front/src/views/home/monitor/index.vue
zzh b178a2a35d
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m56s
feat: 支付选券、优惠入应收展示、申报产品标识编码、创建9610订单等
- 支付弹窗(PC/H5)支持选券与批量支付体验优化,运单管理复用我的订单支付弹窗;立即支付仅允许待支付订单
- 订单详情费用明细展示优惠券抵扣/会员折扣负数行与「含优惠」;价格配置与手工加费过滤抵扣费用项
- 申报明细新增产品标识编码/非标产品标识编码/标准产品标识编码(PC/H5 下单、改单、详情、开放接口文档、公式目录、Excel 模板)
- 9610 页「批量申报」改为「创建9610订单」:选运单并补充出口资质备案、电商平台
- 店铺订单批量按钮按动作独立 loading,获取面单限并发并显示进度
- 另含:国家管理、登录门户切换、监控与工作台、公告弹窗、nginx/vite 配置等改动
2026-10-19 16:34:06 +08:00

1430 lines
45 KiB
Vue

<template>
<div ref="logisticsMonitorRoot" class="logistics-monitor-container">
<div class="logistics-monitor-card">
<div class="logistics-monitor-header">
<h2 class="logistics-monitor-title">{{ $t('monitor.logistics.pageTitle') }}</h2>
</div>
<div class="logistics-monitor-body">
<a-spin :loading="overviewLoading" style="width: 100%">
<a-tabs
v-model:active-key="monitorTab"
type="rounded"
:animation="false"
:justify="true"
class="monitor-top-tabs"
>
<a-tab-pane key="order" :title="$t('monitor.logistics.tab.order')">
<div class="monitor-ops-toolbar">
<span class="monitor-ops-toolbar__label">{{ $t('monitor.logistics.filter.opsPeriod') }}</span>
<a-radio-group v-model="opsDimension" type="button" size="small">
<a-radio value="day">{{ $t('monitor.logistics.filter.periodDay') }}</a-radio>
<a-radio value="week">{{ $t('monitor.logistics.filter.periodWeek') }}</a-radio>
<a-radio value="month">{{ $t('monitor.logistics.filter.periodMonth') }}</a-radio>
</a-radio-group>
</div>
<a-row :gutter="[12, 12]" align="stretch" class="logistics-monitor-stats">
<a-col
v-for="card in opsStatCards"
:key="card.key"
:xs="24"
:sm="12"
:md="8"
:lg="8"
:xl="8"
>
<a-card hoverable class="stat-card" :body-style="{ padding: '12px', height: '100%' }">
<div class="stat-item">
<div class="stat-item__head">
<div class="stat-label">{{ $t(card.labelKey) }}</div>
<div class="stat-item__icon stat-item__icon--primary">
<img v-if="card.iconSrc" :src="card.iconSrc" alt="" class="stat-item__icon-img" />
<icon-bar-chart v-else />
</div>
</div>
<div class="stat-value">{{ card.value }}</div>
<div v-if="card.growth.show" class="stat-footer">
<div class="stat-trend" :class="card.growth.up ? 'up' : 'down'">
<icon-arrow-up v-if="card.growth.up" />
<icon-arrow-down v-else />
{{ card.growth.text }} {{ $t(opsCompareKey) }}
</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
<p class="monitor-queue-title">{{ $t('monitor.logistics.queue.title') }}</p>
<a-row :gutter="[12, 12]" align="stretch" class="logistics-monitor-stats">
<a-col
v-for="card in queueStatCards"
:key="card.key"
:xs="24"
:sm="12"
:md="12"
:lg="6"
:xl="6"
>
<a-card hoverable class="stat-card" :body-style="{ padding: '12px', height: '100%' }">
<div class="stat-item">
<div class="stat-item__head">
<div class="stat-label">{{ $t(card.labelKey) }}</div>
<div
class="stat-item__icon"
:class="card.warning ? 'stat-item__icon--warning' : 'stat-item__icon--primary'"
>
<img v-if="card.iconSrc" :src="card.iconSrc" alt="" class="stat-item__icon-img" />
<icon-bar-chart v-else />
</div>
</div>
<div class="stat-value" :class="{ 'stat-value--warning': card.warning }">
{{ card.value }}
</div>
</div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" class="logistics-monitor-trend-charts">
<a-col :xs="24" :lg="12">
<a-card :title="$t(orderTrendTitleKey)" class="chart-card">
<div ref="orderChartRef" class="chart-host"></div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="$t(revenueTrendTitleKey)" class="chart-card">
<div ref="profitChartRef" class="chart-host"></div>
</a-card>
</a-col>
</a-row>
</a-tab-pane>
<a-tab-pane key="customer" :title="$t('monitor.logistics.tab.customer')">
<a-row :gutter="[12, 12]" align="stretch" class="logistics-monitor-stats">
<a-col
v-for="card in customerStatCards"
:key="card.key"
:xs="24"
:sm="12"
:md="12"
:lg="12"
:xl="6"
>
<a-card hoverable class="stat-card" :body-style="{ padding: '12px', height: '100%' }">
<div class="stat-item">
<div class="stat-item__head">
<div class="stat-label">{{ $t(card.labelKey) }}</div>
<div
class="stat-item__icon"
:class="card.warning ? 'stat-item__icon--warning' : card.icon === 'online' ? 'stat-item__icon--online' : 'stat-item__icon--primary'"
>
<icon-user-group />
</div>
</div>
<div class="stat-value" :class="{ 'stat-value--warning': card.warning }">{{ card.value }}</div>
<div class="stat-footer">
<div v-if="card.growth.show" class="stat-trend" :class="card.growth.up ? 'up' : 'down'">
<icon-arrow-up v-if="card.growth.up" />
<icon-arrow-down v-else />
{{ card.growth.text }} {{ $t(card.compareKey) }}
</div>
<div
v-if="card.hintKey"
class="stat-hint"
:class="{ 'stat-hint--accent': card.hintAccent }"
>
{{ $t(card.hintKey) }}
</div>
<div v-if="card.extraHintKey" class="stat-hint">{{ $t(card.extraHintKey) }}</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" class="logistics-monitor-customer-trends">
<a-col :span="24">
<a-card class="chart-card">
<template #title>
{{ $t('monitor.logistics.chart.customerTrendsSection') }}
</template>
<div class="sign-rate-toolbar pie-toolbar">
<span class="sign-rate-toolbar__label">{{ $t('monitor.logistics.filter.line') }}</span>
<a-select
v-model="customerTrendLine"
:options="lineFilterOptions"
:style="{ width: '160px' }"
allow-clear
/>
<span class="sign-rate-toolbar__label">{{ $t('monitor.logistics.filter.statPeriod') }}</span>
<a-radio-group v-model="customerTrendPeriod" type="button" size="small">
<a-radio value="week">{{ $t('monitor.logistics.filter.periodWeek') }}</a-radio>
<a-radio value="month">{{ $t('monitor.logistics.filter.periodMonth') }}</a-radio>
</a-radio-group>
</div>
<a-row :gutter="[12, 12]" class="customer-trend-charts-row">
<a-col :xs="24" :lg="12">
<div class="customer-chart-block">
<div class="customer-chart-block__title">
{{ $t('monitor.logistics.chart.customerOrdersByLine') }}
</div>
<div ref="customerOrderTrendRef" class="chart-host"></div>
</div>
</a-col>
<a-col :xs="24" :lg="12">
<div class="customer-chart-block">
<div class="customer-chart-block__title">
{{ $t('monitor.logistics.chart.customerReceivableByLine') }}
</div>
<div ref="customerReceivableTrendRef" class="chart-host"></div>
</div>
</a-col>
</a-row>
</a-card>
</a-col>
</a-row>
</a-tab-pane>
<a-tab-pane key="track" :title="$t('monitor.logistics.tab.track')">
<a-row :gutter="[16, 16]" class="logistics-monitor-sign-rate-row">
<a-col :span="24">
<a-card class="chart-card">
<template #title>
{{ $t('monitor.logistics.chart.signRateByLine') }}
</template>
<div class="sign-rate-toolbar">
<span class="sign-rate-toolbar__label">{{ $t('monitor.logistics.filter.line') }}</span>
<a-select
v-model="filterLine"
:options="lineFilterOptions"
:style="{ width: '160px' }"
allow-clear
/>
<span class="sign-rate-toolbar__label">{{ $t('monitor.logistics.filter.signDays') }}</span>
<a-select
v-model="filterSignDays"
:options="signDaysFilterOptions"
:style="{ width: '120px' }"
/>
</div>
<div ref="signRateChartRef" class="chart-host chart-host--sign-rate"></div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" class="logistics-monitor-pie-row">
<a-col :span="24">
<a-card class="chart-card">
<template #title>
{{ $t('monitor.logistics.pie.sectionTitle') }}
</template>
<div class="sign-rate-toolbar pie-toolbar">
<span class="sign-rate-toolbar__label">{{ $t('monitor.logistics.filter.line') }}</span>
<a-select
v-model="pieFilterLine"
:options="lineFilterOptions"
:style="{ width: '160px' }"
allow-clear
/>
<span class="sign-rate-toolbar__label">{{ $t('monitor.logistics.filter.statPeriod') }}</span>
<a-radio-group v-model="piePeriod" type="button" size="small">
<a-radio value="week">{{ $t('monitor.logistics.filter.periodWeek') }}</a-radio>
<a-radio value="month">{{ $t('monitor.logistics.filter.periodMonth') }}</a-radio>
</a-radio-group>
</div>
<a-row :gutter="[12, 12]" class="pie-charts-row">
<a-col :xs="24" :md="8">
<div class="pie-chart-block">
<div class="pie-chart-block__title">{{ $t('monitor.logistics.pie.trackComplete') }}</div>
<div ref="pieTrackCompleteRef" class="chart-host chart-host--pie"></div>
</div>
</a-col>
<a-col :xs="24" :md="8">
<div class="pie-chart-block">
<div class="pie-chart-block__title">{{ $t('monitor.logistics.pie.trackTimely') }}</div>
<div ref="pieTrackTimelyRef" class="chart-host chart-host--pie"></div>
</div>
</a-col>
<a-col :xs="24" :md="8">
<div class="pie-chart-block">
<div class="pie-chart-block__title">{{ $t('monitor.logistics.pie.otd') }}</div>
<div ref="pieOtdRef" class="chart-host chart-host--pie"></div>
</div>
</a-col>
</a-row>
</a-card>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
</a-spin>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
import * as echarts from 'echarts';
import { modifyAlpha } from 'zrender/lib/tool/color';
import { useI18n } from 'vue-i18n';
import {
getMonitorOverview,
formatMonitorNumber,
formatMonitorCurrency,
formatMonitorGrowth,
type MonitorDimension,
type MonitorOverview,
} from '@/api/monitor';
import iconOrderNew from '@/assets/icons/workplace/order-new.svg?url';
import iconPayment from '@/assets/icons/workplace/order-payment.svg?url';
import iconGrowth from '@/assets/icons/workplace/order-growth.svg?url';
import iconPendingOnline from '@/assets/icons/workplace/order-pending-online.svg?url';
import iconProblematic from '@/assets/icons/workplace/order-problematic.svg?url';
import iconShipped from '@/assets/icons/workplace/order-shipped.svg?url';
defineOptions({ name: 'Monitor' });
const { t } = useI18n();
const overview = ref<MonitorOverview | null>(null);
const overviewLoading = ref(true);
const opsDimension = ref<MonitorDimension>('day');
async function loadOverview() {
overviewLoading.value = true;
try {
overview.value = await getMonitorOverview(opsDimension.value);
} catch {
overview.value = null;
} finally {
overviewLoading.value = false;
await nextTick();
refreshChartsForTab(monitorTab.value);
}
}
function refreshChartsForTab(tab: 'customer' | 'order' | 'track') {
if (tab === 'order') ensureOrderLineCharts();
if (tab === 'track') ensureTrackCharts();
if (tab === 'customer') ensureCustomerCharts();
nextTick(() => onResize());
}
const opsCompareKey = computed(() => {
if (opsDimension.value === 'week') return 'monitor.logistics.vsLastWeek';
if (opsDimension.value === 'month') return 'monitor.logistics.vsLastMonth';
return 'monitor.logistics.vsYesterday';
});
const orderTrendTitleKey = computed(() => {
if (opsDimension.value === 'week') return 'monitor.logistics.chart.ordersWeek';
if (opsDimension.value === 'month') return 'monitor.logistics.chart.ordersMonth';
return 'monitor.logistics.chart.ordersDay';
});
const revenueTrendTitleKey = computed(() => {
if (opsDimension.value === 'week') return 'monitor.logistics.chart.revenueGrossWeek';
if (opsDimension.value === 'month') return 'monitor.logistics.chart.revenueGrossMonth';
return 'monitor.logistics.chart.revenueGrossDay';
});
const opsStatCards = computed(() => {
const ops = overview.value?.ops;
const dim = opsDimension.value;
return [
{
key: 'orderCount',
labelKey: `monitor.logistics.ops.orderCount.${dim}`,
value: formatMonitorNumber(ops?.orderCount ?? 0),
growth: formatMonitorGrowth(ops?.orderCountGrowthPercent),
iconSrc: iconOrderNew,
},
{
key: 'revenue',
labelKey: `monitor.logistics.ops.revenue.${dim}`,
value: formatMonitorCurrency(ops?.revenue ?? 0),
growth: formatMonitorGrowth(ops?.revenueGrowthPercent),
iconSrc: iconPayment,
},
{
key: 'grossProfit',
labelKey: `monitor.logistics.ops.grossProfit.${dim}`,
value: formatMonitorCurrency(ops?.grossProfit ?? 0),
growth: formatMonitorGrowth(ops?.grossProfitGrowthPercent),
iconSrc: iconGrowth,
},
];
});
const queueStatCards = computed(() => {
const queue = overview.value?.queue ?? overview.value?.order;
return [
{
key: 'pendingPayment',
labelKey: 'monitor.logistics.stat.pendingPayment',
value: formatMonitorNumber(queue?.pendingPayment ?? 0),
warning: (queue?.pendingPayment ?? 0) > 0,
iconSrc: iconPayment,
},
{
key: 'pendingOnline',
labelKey: 'monitor.logistics.stat.pendingOnline',
value: formatMonitorNumber(queue?.pendingOnline ?? 0),
warning: (queue?.pendingOnline ?? 0) > 0,
iconSrc: iconPendingOnline,
},
{
key: 'pendingSign',
labelKey: 'monitor.logistics.stat.pendingSign',
value: formatMonitorNumber(queue?.pendingSign ?? 0),
warning: false,
iconSrc: iconShipped,
},
{
key: 'orderExceptions',
labelKey: 'monitor.logistics.stat.orderExceptions',
value: formatMonitorNumber(queue?.problematic ?? 0),
warning: (queue?.problematic ?? 0) > 0,
iconSrc: iconProblematic,
},
];
});
const customerStatCards = computed(() => {
const customer = overview.value?.customer;
return [
{
key: 'totalCustomers',
labelKey: 'monitor.logistics.stat.totalCustomers',
value: formatMonitorNumber(customer?.totalCustomers ?? 0),
growth: formatMonitorGrowth(customer?.totalCustomersMonthGrowthPercent),
compareKey: 'monitor.logistics.vsLastMonth',
warning: false,
icon: 'group',
},
{
key: 'newCustomers',
labelKey: 'monitor.logistics.stat.newCustomers',
value: formatMonitorNumber(customer?.newCustomersToday ?? 0),
growth: formatMonitorGrowth(customer?.newCustomersGrowthPercent),
compareKey: 'monitor.logistics.vsYesterday',
warning: false,
icon: 'add',
hintKey: 'monitor.logistics.hint.newCustomersToday',
},
{
key: 'onlineCustomers',
labelKey: 'monitor.logistics.stat.onlineCustomers',
value: formatMonitorNumber(customer?.onlineCustomers ?? 0),
growth: { show: false, up: true, text: '' },
warning: false,
icon: 'online',
hintKey: 'monitor.logistics.hint.onlineRealtime',
hintAccent: true,
},
{
key: 'inactiveLogin7d',
labelKey: 'monitor.logistics.stat.inactiveLogin7d',
value: formatMonitorNumber(customer?.inactiveLogin7d ?? 0),
growth: { show: false, up: true, text: '' },
warning: true,
icon: 'clock',
hintKey: 'monitor.logistics.hint.inactiveLogin7d',
extraHintKey: 'monitor.logistics.hint.inactiveLogin7dCompare',
},
];
});
const LINE_KEYS = ['us', 'eu', 'sea', 'au', 'jp', 'uk'] as const;
type LineKey = (typeof LINE_KEYS)[number];
const baseSignRates: Record<LineKey, number> = {
us: 93.2,
eu: 89.5,
sea: 91.0,
au: 87.3,
jp: 94.1,
uk: 90.0,
};
const orderChartRef = ref<HTMLElement | null>(null);
const profitChartRef = ref<HTMLElement | null>(null);
const signRateChartRef = ref<HTMLElement | null>(null);
const customerOrderTrendRef = ref<HTMLElement | null>(null);
const customerReceivableTrendRef = ref<HTMLElement | null>(null);
const pieTrackCompleteRef = ref<HTMLElement | null>(null);
const pieTrackTimelyRef = ref<HTMLElement | null>(null);
const pieOtdRef = ref<HTMLElement | null>(null);
const logisticsMonitorRoot = ref<HTMLElement | null>(null);
const monitorTab = ref<'customer' | 'order' | 'track'>('order');
let layoutResizeObserver: ResizeObserver | null = null;
const filterLine = ref<string | undefined>('all');
const filterSignDays = ref(7);
const pieFilterLine = ref<string | undefined>('all');
const piePeriod = ref<'week' | 'month'>('week');
const customerTrendLine = ref<string | undefined>('all');
const customerTrendPeriod = ref<'week' | 'month'>('week');
type PieMetricKey = 'complete' | 'timely' | 'otd';
const pieMetricBase: Record<LineKey, Record<PieMetricKey, number>> = {
us: { complete: 92.4, timely: 89.1, otd: 91.3 },
eu: { complete: 88.2, timely: 85.6, otd: 87.9 },
sea: { complete: 90.5, timely: 88.0, otd: 89.4 },
au: { complete: 86.8, timely: 84.2, otd: 86.1 },
jp: { complete: 93.1, timely: 90.8, otd: 92.5 },
uk: { complete: 89.4, timely: 87.0, otd: 88.6 },
};
let orderChartInstance: echarts.ECharts | null = null;
let profitChartInstance: echarts.ECharts | null = null;
let signRateChartInstance: echarts.ECharts | null = null;
let customerOrderTrendInstance: echarts.ECharts | null = null;
let customerReceivableTrendInstance: echarts.ECharts | null = null;
let pieTrackCompleteInstance: echarts.ECharts | null = null;
let pieTrackTimelyInstance: echarts.ECharts | null = null;
let pieOtdInstance: echarts.ECharts | null = null;
const lineFilterOptions = computed(() => [
{ label: t('monitor.logistics.filter.allLines'), value: 'all' },
...LINE_KEYS.map((key) => ({
label: t(`monitor.logistics.line.${key}`),
value: key,
})),
]);
const signDaysFilterOptions = computed(() =>
[3, 5, 7, 10, 15].map((n) => ({
label: t('monitor.logistics.filter.signDaysOption', { n }),
value: n,
})),
);
function buildSignRateSeries(): { categories: string[]; values: number[] } {
const lineKey = filterLine.value;
const keys: LineKey[] =
!lineKey || lineKey === 'all'
? [...LINE_KEYS]
: LINE_KEYS.includes(lineKey as LineKey)
? [lineKey as LineKey]
: [...LINE_KEYS];
const rateMap = new Map(
(overview.value?.regionSignRates ?? []).map((item) => [item.region, item.rate]),
);
const categories: string[] = [];
const values: number[] = [];
keys.forEach((key) => {
categories.push(t(`monitor.logistics.line.${key}`));
values.push(Number(rateMap.get(key) ?? 0));
});
return { categories, values };
}
function renderSignRateChart() {
if (!signRateChartRef.value) return;
const primary = readCssVar('--app-primary-color', '#2D8C8C');
const axisLine = readCssVar('--color-border-2', '#e5e6eb');
const { categories, values } = buildSignRateSeries();
if (!signRateChartInstance) {
signRateChartInstance = echarts.init(signRateChartRef.value);
}
signRateChartInstance.setOption({
grid: { left: '3%', right: '3%', bottom: '3%', top: '8%', containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (val: number) => `${val}%`,
},
xAxis: {
type: 'category',
data: categories,
axisTick: { alignWithLabel: true },
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { interval: 0, rotate: categories.length > 6 ? 24 : 0 },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%' },
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: axisLine, opacity: 0.35 } },
name: t('monitor.logistics.axis.complianceRate'),
nameTextStyle: { color: readCssVar('--color-text-2', '#86909c'), fontSize: 12 },
},
series: [
{
name: t('monitor.logistics.series.signRate'),
type: 'bar',
barMaxWidth: 48,
data: values,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: modifyAlpha(primary, 0.95) },
{ offset: 1, color: modifyAlpha(primary, 0.45) },
]),
borderRadius: [4, 4, 0, 0],
},
label: {
show: true,
position: 'top',
formatter: (p: { value: number }) => `${p.value}%`,
fontSize: 11,
color: readCssVar('--color-text-2', '#86909c'),
},
},
],
});
}
function getActiveLineKeysForPie(): LineKey[] {
const v = pieFilterLine.value;
if (!v || v === 'all') return [...LINE_KEYS];
return LINE_KEYS.includes(v as LineKey) ? [v as LineKey] : [...LINE_KEYS];
}
function getPiePassRate(metric: PieMetricKey): number {
const rates = overview.value?.trackRates;
if (!rates) return 0;
if (metric === 'complete') return rates.trackCompleteRate;
if (metric === 'timely') return rates.trackTimelyRate;
return rates.otdRate;
}
function averagePieMetric(metric: PieMetricKey): number {
const pass = getPiePassRate(metric);
return Math.min(99.5, Math.max(0, Number(pass.toFixed(1))));
}
function buildPieChartOption(passColor: string, metric: PieMetricKey) {
const pass = averagePieMetric(metric);
const fail = Number((100 - pass).toFixed(1));
const neutral = readCssVar('--color-fill-3', '#e5e6eb');
const passName = t('monitor.logistics.pie.pass');
const failName = t('monitor.logistics.pie.fail');
return {
tooltip: {
trigger: 'item' as const,
formatter: `{b}: {c}% ({d}%)`,
},
legend: {
bottom: 0,
icon: 'circle',
textStyle: { fontSize: 12, color: readCssVar('--color-text-2', '#86909c') },
},
series: [
{
type: 'pie' as const,
radius: ['44%', '72%'],
center: ['50%', '46%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: {
formatter: '{b}\n{d}%',
fontSize: 11,
color: readCssVar('--color-text-2', '#86909c'),
},
labelLine: { length: 10, length2: 8 },
data: [
{ name: passName, value: pass, itemStyle: { color: passColor } },
{ name: failName, value: fail, itemStyle: { color: neutral } },
],
},
],
};
}
function renderPieCharts() {
const primary = readCssVar('--app-primary-color', '#2D8C8C');
const timelyColor = readCssVar('--color-link-6', '#14C9C9');
const otdColor = readCssVar('--color-warning-6', '#FF7D00');
if (pieTrackCompleteRef.value) {
if (!pieTrackCompleteInstance) pieTrackCompleteInstance = echarts.init(pieTrackCompleteRef.value);
pieTrackCompleteInstance.setOption(buildPieChartOption(primary, 'complete'));
}
if (pieTrackTimelyRef.value) {
if (!pieTrackTimelyInstance) pieTrackTimelyInstance = echarts.init(pieTrackTimelyRef.value);
pieTrackTimelyInstance.setOption(buildPieChartOption(timelyColor, 'timely'));
}
if (pieOtdRef.value) {
if (!pieOtdInstance) pieOtdInstance = echarts.init(pieOtdRef.value);
pieOtdInstance.setOption(buildPieChartOption(otdColor, 'otd'));
}
}
function readCssVar(name: string, fallback: string): string {
if (typeof document === 'undefined') return fallback;
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
function onResize() {
orderChartInstance?.resize();
profitChartInstance?.resize();
signRateChartInstance?.resize();
customerOrderTrendInstance?.resize();
customerReceivableTrendInstance?.resize();
pieTrackCompleteInstance?.resize();
pieTrackTimelyInstance?.resize();
pieOtdInstance?.resize();
}
function getLineTrendColors(): string[] {
const primary = readCssVar('--app-primary-color', '#2D8C8C');
return [primary, '#00B42A', '#FF7D00', '#14C9C9', '#722ED1', '#F5319D'];
}
function getActiveCustomerLineKeys(): LineKey[] {
const v = customerTrendLine.value;
if (!v || v === 'all') return [...LINE_KEYS];
return LINE_KEYS.includes(v as LineKey) ? [v as LineKey] : [...LINE_KEYS];
}
function buildCustomerPeriodLabels(period: 'week' | 'month'): string[] {
const n = period === 'week' ? 8 : 6;
return Array.from({ length: n }, (_, i) =>
period === 'week'
? t('monitor.logistics.axis.weekIndex', { n: i + 1 })
: t('monitor.logistics.axis.monthIndex', { n: i + 1 }),
);
}
const orderBaseByLine: Record<LineKey, number> = {
us: 420,
eu: 310,
sea: 280,
au: 190,
jp: 350,
uk: 260,
};
function buildMockOrderSeries(line: LineKey, period: 'week' | 'month', len: number): number[] {
const scale = period === 'month' ? 4.2 : 1;
const b = orderBaseByLine[line] * scale;
const idx = LINE_KEYS.indexOf(line);
return Array.from({ length: len }, (_, i) => {
const wave = Math.sin((i + idx) * 0.55) * (period === 'month' ? 45 : 12);
const trend = i * (period === 'month' ? 8 : 3);
return Math.round(b + wave + trend);
});
}
function buildMockReceivableSeries(line: LineKey, period: 'week' | 'month', len: number): number[] {
const orders = buildMockOrderSeries(line, period, len);
const unitPrice = 280 + LINE_KEYS.indexOf(line) * 15;
return orders.map((o) => Math.round(o * unitPrice));
}
function getRegionTrendBundle(kind: 'order' | 'receivable', period: 'week' | 'month') {
const bundle =
kind === 'order' ? overview.value?.regionOrderTrend : overview.value?.regionReceivableTrend;
return period === 'month' ? bundle?.month : bundle?.week;
}
function renderCustomerTrendCharts() {
const period = customerTrendPeriod.value;
const orderTrend = getRegionTrendBundle('order', period);
const receivableTrend = getRegionTrendBundle('receivable', period);
const categories =
orderTrend?.labels?.length
? orderTrend.labels
: buildCustomerPeriodLabels(period);
const keys = getActiveCustomerLineKeys();
const colors = getLineTrendColors();
const axisLine = readCssVar('--color-border-2', '#e5e6eb');
const text2 = readCssVar('--color-text-2', '#86909c');
const orderSeries = keys.map((key) => {
const matched = orderTrend?.series?.find((item) => item.region === key);
return {
name: t(`monitor.logistics.line.${key}`),
type: 'line' as const,
smooth: true,
symbol: 'circle',
symbolSize: keys.length > 2 ? 4 : 6,
data: matched?.values?.length ? matched.values : Array(categories.length).fill(0),
itemStyle: { color: colors[LINE_KEYS.indexOf(key) % colors.length] },
lineStyle: { width: keys.length > 2 ? 2 : 2.5 },
};
});
const receivableSeries = keys.map((key) => {
const matched = receivableTrend?.series?.find((item) => item.region === key);
return {
name: t(`monitor.logistics.line.${key}`),
type: 'line' as const,
smooth: true,
symbol: 'circle',
symbolSize: keys.length > 2 ? 4 : 6,
data: matched?.values?.length ? matched.values : Array(categories.length).fill(0),
itemStyle: { color: colors[LINE_KEYS.indexOf(key) % colors.length] },
lineStyle: { width: keys.length > 2 ? 2 : 2.5 },
};
});
const grid = { left: '3%', right: '3%', bottom: keys.length > 1 ? '14%' : '3%', top: '10%', containLabel: true };
const baseOption = {
grid,
legend: {
show: keys.length > 1,
type: 'scroll' as const,
bottom: 0,
textStyle: { fontSize: 11, color: text2 },
},
xAxis: {
type: 'category' as const,
data: categories,
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: text2, interval: 0, rotate: categories.length > 7 ? 18 : 0 },
},
yAxis: {
type: 'value' as const,
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: axisLine, opacity: 0.35 } },
axisLabel: { color: text2 },
},
};
if (customerOrderTrendRef.value) {
if (!customerOrderTrendInstance) {
customerOrderTrendInstance = echarts.init(customerOrderTrendRef.value);
}
customerOrderTrendInstance.setOption(
{
...baseOption,
tooltip: {
trigger: 'axis',
},
series: orderSeries,
},
{ notMerge: true },
);
}
if (customerReceivableTrendRef.value) {
if (!customerReceivableTrendInstance) {
customerReceivableTrendInstance = echarts.init(customerReceivableTrendRef.value);
}
customerReceivableTrendInstance.setOption(
{
...baseOption,
tooltip: {
trigger: 'axis',
valueFormatter: (val: number) => `¥ ${Number(val).toLocaleString()}`,
},
yAxis: {
...baseOption.yAxis,
axisLabel: {
color: text2,
formatter: (v: number) => `¥${(v / 1000).toFixed(0)}k`,
},
},
series: receivableSeries,
},
{ notMerge: true },
);
}
}
function ensureCustomerCharts() {
renderCustomerTrendCharts();
customerOrderTrendInstance?.resize();
customerReceivableTrendInstance?.resize();
}
function ensureOrderLineCharts() {
const primary = readCssVar('--app-primary-color', '#2D8C8C');
const axisLine = readCssVar('--color-border-2', '#e5e6eb');
const orderTrend = overview.value?.orderTrend ?? overview.value?.orderTrend7d ?? [];
const revenueTrend = overview.value?.revenueTrend ?? overview.value?.revenueTrend7d ?? [];
const dayLabels = orderTrend.length
? orderTrend.map((item) => item.label)
: revenueTrend.map((item) => item.label);
const orderValues = orderTrend.length
? orderTrend.map((item) => item.count)
: [];
const revenueValues = revenueTrend.map((item) => item.revenue);
const grossValues = revenueTrend.map((item) => item.grossProfit);
if (orderChartRef.value) {
if (!orderChartInstance) {
orderChartInstance = echarts.init(orderChartRef.value);
}
orderChartInstance.setOption(
{
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: dayLabels.length ? dayLabels : [t('workplace.record.nodata')],
axisLine: { lineStyle: { color: axisLine } },
},
yAxis: { type: 'value', minInterval: 1, axisLine: { lineStyle: { color: axisLine } } },
series: [
{
name: t('monitor.logistics.series.orders'),
type: 'line',
smooth: true,
data: orderValues,
itemStyle: { color: primary },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: modifyAlpha(primary, 0.2) },
{ offset: 1, color: modifyAlpha(primary, 0) },
]),
},
},
],
},
{ notMerge: true },
);
}
if (profitChartRef.value) {
if (!profitChartInstance) {
profitChartInstance = echarts.init(profitChartRef.value);
}
const revenueColor = '#00B42A';
const grossColor = '#FF7D00';
profitChartInstance.setOption(
{
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
tooltip: { trigger: 'axis' },
legend: {
data: [t('monitor.logistics.series.revenue'), t('monitor.logistics.series.grossProfit')],
bottom: 0,
},
xAxis: {
type: 'category',
data: dayLabels.length ? dayLabels : [t('workplace.record.nodata')],
axisLine: { lineStyle: { color: axisLine } },
},
yAxis: { type: 'value', axisLine: { lineStyle: { color: axisLine } } },
series: [
{
name: t('monitor.logistics.series.revenue'),
type: 'line',
smooth: true,
data: revenueValues,
itemStyle: { color: revenueColor },
},
{
name: t('monitor.logistics.series.grossProfit'),
type: 'line',
smooth: true,
data: grossValues,
itemStyle: { color: grossColor },
},
],
},
{ notMerge: true },
);
}
orderChartInstance?.resize();
profitChartInstance?.resize();
}
function ensureTrackCharts() {
renderSignRateChart();
renderPieCharts();
signRateChartInstance?.resize();
pieTrackCompleteInstance?.resize();
pieTrackTimelyInstance?.resize();
pieOtdInstance?.resize();
}
onMounted(async () => {
await nextTick();
window.addEventListener('resize', onResize);
if (typeof ResizeObserver !== 'undefined' && logisticsMonitorRoot.value) {
layoutResizeObserver = new ResizeObserver(() => onResize());
layoutResizeObserver.observe(logisticsMonitorRoot.value);
}
await loadOverview();
});
function disposeOrderCharts() {
orderChartInstance?.dispose();
profitChartInstance?.dispose();
orderChartInstance = null;
profitChartInstance = null;
}
function disposeTrackCharts() {
signRateChartInstance?.dispose();
pieTrackCompleteInstance?.dispose();
pieTrackTimelyInstance?.dispose();
pieOtdInstance?.dispose();
signRateChartInstance = null;
pieTrackCompleteInstance = null;
pieTrackTimelyInstance = null;
pieOtdInstance = null;
}
function disposeCustomerCharts() {
customerOrderTrendInstance?.dispose();
customerReceivableTrendInstance?.dispose();
customerOrderTrendInstance = null;
customerReceivableTrendInstance = null;
}
watch(
monitorTab,
(key, prev) => {
if (prev === 'order') disposeOrderCharts();
if (prev === 'track') disposeTrackCharts();
if (prev === 'customer') disposeCustomerCharts();
nextTick(() => {
if (key === 'order') ensureOrderLineCharts();
if (key === 'track') ensureTrackCharts();
if (key === 'customer') ensureCustomerCharts();
nextTick(() => onResize());
});
},
{ immediate: true },
);
watch(opsDimension, () => {
void loadOverview();
});
watch([filterLine, filterSignDays], () => {
nextTick(() => {
if (monitorTab.value === 'track') renderSignRateChart();
});
});
watch([pieFilterLine, piePeriod], () => {
nextTick(() => {
if (monitorTab.value === 'track') renderPieCharts();
});
});
watch([customerTrendLine, customerTrendPeriod], () => {
nextTick(() => {
if (monitorTab.value === 'customer') renderCustomerTrendCharts();
});
});
onUnmounted(() => {
layoutResizeObserver?.disconnect();
layoutResizeObserver = null;
window.removeEventListener('resize', onResize);
orderChartInstance?.dispose();
profitChartInstance?.dispose();
signRateChartInstance?.dispose();
customerOrderTrendInstance?.dispose();
customerReceivableTrendInstance?.dispose();
pieTrackCompleteInstance?.dispose();
pieTrackTimelyInstance?.dispose();
pieOtdInstance?.dispose();
orderChartInstance = null;
profitChartInstance = null;
signRateChartInstance = null;
customerOrderTrendInstance = null;
customerReceivableTrendInstance = null;
pieTrackCompleteInstance = null;
pieTrackTimelyInstance = null;
pieOtdInstance = null;
});
</script>
<style scoped lang="less">
.logistics-monitor-container {
box-sizing: border-box;
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
padding: 16px;
background: var(--app-page-bg);
overflow: hidden;
}
.logistics-monitor-card {
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
min-height: 0;
height: 100%;
max-height: 100%;
background: var(--app-card-bg);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
padding: 16px;
box-sizing: border-box;
overflow: hidden;
}
.logistics-monitor-header {
margin-bottom: 16px;
flex-shrink: 0;
text-align: center;
}
.logistics-monitor-body {
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
}
.monitor-top-tabs {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
:deep(.arco-tabs) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
:deep(.arco-tabs-nav) {
justify-content: flex-start;
flex-shrink: 0;
}
:deep(.arco-tabs-nav-tab-list) {
justify-content: flex-start;
flex-wrap: wrap;
}
:deep(.arco-tabs-content) {
flex: 1;
min-height: 0;
min-width: 0;
padding: 12px 0 0;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
:deep(.arco-tabs-content-list) {
height: 100%;
}
:deep(.arco-tabs-pane) {
overflow: visible;
}
}
.logistics-monitor-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: var(--color-text-1);
}
.monitor-ops-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.monitor-ops-toolbar__label {
font-size: 13px;
color: var(--color-text-2);
white-space: nowrap;
}
.monitor-queue-title {
margin: 16px 0 10px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.logistics-monitor-stats {
margin-bottom: 10px;
:deep(.arco-col) {
display: flex;
min-width: 0;
}
}
.logistics-monitor-trend-charts {
margin-top: 0;
}
.logistics-monitor-customer-trends {
margin-top: 0;
}
.customer-trend-charts-row {
margin-top: 4px;
}
.customer-chart-block {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
min-height: 0;
}
.customer-chart-block__title {
text-align: center;
font-size: clamp(13px, 1.5vw, 14px);
font-weight: 600;
color: var(--color-text-1);
margin-bottom: 4px;
flex-shrink: 0;
}
.logistics-monitor-sign-rate-row {
margin-top: 0;
}
.sign-rate-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.sign-rate-toolbar__label {
font-size: 14px;
color: var(--color-text-2);
white-space: nowrap;
}
.stat-card {
flex: 1;
width: 100%;
min-height: 0;
:deep(.arco-card) {
height: 100%;
display: flex;
flex-direction: column;
}
:deep(.arco-card-body) {
flex: 1;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.stat-item__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
min-height: 40px;
}
.stat-item__icon {
flex-shrink: 0;
margin-top: -2px;
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.stat-item__icon-img {
width: 28px;
height: 28px;
object-fit: contain;
}
.stat-item__icon--primary {
background: var(--app-primary-color-light);
color: var(--app-primary-color);
}
.stat-item__icon--warning {
background: rgb(var(--orange-1));
color: rgb(var(--orange-6));
}
.stat-item__icon--danger {
background: rgb(var(--red-1));
color: rgb(var(--red-6));
}
.stat-item__icon--online {
background: rgb(var(--cyan-1));
color: rgb(var(--cyan-6));
}
.stat-label {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.35;
color: var(--color-text-2);
padding-top: 2px;
padding-right: 4px;
}
.stat-value {
font-size: clamp(18px, 2.4vw, 22px);
font-weight: 600;
line-height: 1.25;
color: var(--color-text-1);
word-break: break-all;
}
.stat-value--warning {
color: rgb(var(--orange-6));
}
.stat-value--danger {
color: rgb(var(--red-6));
}
.stat-footer {
margin-top: auto;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 4px;
padding-top: 8px;
}
.stat-trend {
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.stat-trend.up {
color: rgb(var(--green-6));
}
.stat-trend.down {
color: rgb(var(--red-6));
}
.stat-hint {
font-size: 12px;
line-height: 1.35;
color: var(--color-text-3);
}
.stat-hint--accent {
color: var(--app-primary-color);
}
.chart-card {
height: auto;
width: 100%;
}
.chart-host {
width: 100%;
height: clamp(200px, min(32vh, 36vw), 380px);
min-height: 200px;
max-height: 420px;
}
.chart-host--sign-rate {
height: clamp(220px, min(34vh, 38vw), 400px);
min-height: 220px;
max-height: 440px;
}
.logistics-monitor-pie-row {
margin-top: 0;
}
.pie-toolbar {
margin-bottom: 8px;
}
.pie-charts-row {
margin-top: 4px;
}
.pie-chart-block {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
min-height: 0;
}
.pie-chart-block__title {
text-align: center;
font-size: clamp(13px, 1.5vw, 14px);
font-weight: 600;
color: var(--color-text-1);
margin-bottom: 4px;
flex-shrink: 0;
}
.chart-host--pie {
width: 100%;
height: clamp(200px, min(28vh, 32vw), 340px);
min-height: 200px;
max-height: 380px;
flex: 0 0 auto;
}
@media (max-width: 768px) {
.chart-host {
max-height: none;
height: clamp(200px, 40vw, 280px);
}
.chart-host--sign-rate {
max-height: none;
height: clamp(210px, 42vw, 300px);
}
.chart-host--pie {
max-height: none;
height: clamp(190px, 55vw, 260px);
}
}
</style>