front/src/views/home/monitor/index.vue
zzh fbf1064ab8
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m41s
style: 统一压感卡片阴影并精简充值/监控页交互
对齐登录、工作台、H5、官网与充值页的轻压 inset;监控改胶囊 Tab 减闪;充值页去步骤条并收窄布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 17:03:18 +08:00

1472 lines
47 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 ref="logisticsMonitorRoot" class="logistics-monitor-container">
<div class="monitor-topbar">
<h2 class="logistics-monitor-title">{{ $t('monitor.logistics.pageTitle') }}</h2>
<SlidingTabs
v-model="monitorTab"
class="monitor-top-tabs"
:items="monitorTabItems"
:aria-label="$t('monitor.logistics.pageTitle')"
/>
<div class="monitor-topbar__spacer" />
<div v-show="monitorTab === 'order'" 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>
</div>
<div class="logistics-monitor-body">
<a-spin :loading="overviewLoading" style="width: 100%; height: 100%">
<div class="monitor-shell">
<div class="monitor-tab-panels">
<div v-show="monitorTab === 'order'" class="monitor-tab-panel">
<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 14px 10px', height: '100%' }">
<div class="stat-item">
<div class="stat-item__main">
<div class="stat-item__meta">
<img
v-if="card.iconSrc"
:src="card.iconSrc"
alt=""
class="stat-item__avatar"
/>
<icon-bar-chart v-else class="stat-item__avatar-icon" />
<span class="stat-label">{{ $t(card.labelKey) }}</span>
</div>
<div class="stat-item__body">
<span class="stat-value">{{ card.value }}</span>
</div>
</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 14px 10px', height: '100%' }">
<div class="stat-item">
<div class="stat-item__main">
<div class="stat-item__meta">
<img
v-if="card.iconSrc"
:src="card.iconSrc"
alt=""
class="stat-item__avatar"
/>
<icon-bar-chart v-else class="stat-item__avatar-icon" />
<span class="stat-label">{{ $t(card.labelKey) }}</span>
</div>
<div class="stat-item__body">
<span class="stat-value" :class="{ 'stat-value--warning': card.warning }">
{{ card.value }}
</span>
</div>
</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>
</div>
<div v-show="monitorTab === 'customer'" class="monitor-tab-panel">
<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 14px 10px', height: '100%' }">
<div class="stat-item">
<div class="stat-item__main">
<div class="stat-item__meta">
<icon-user-group
class="stat-item__avatar-icon"
:class="{
'is-warning': card.warning,
'is-online': card.icon === 'online',
}"
/>
<span class="stat-label">{{ $t(card.labelKey) }}</span>
</div>
<div class="stat-item__body">
<span class="stat-value" :class="{ 'stat-value--warning': card.warning }">
{{ card.value }}
</span>
</div>
</div>
<div
v-if="card.growth.show || card.hintKey || card.extraHintKey"
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>
</div>
<div v-show="monitorTab === 'track'" class="monitor-tab-panel">
<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>
</div>
</div>
</div>
</a-spin>
</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 SlidingTabs from '@/components/SlidingTabs/index.vue';
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 monitorTabItems = computed(() => [
{ key: 'order', label: t('monitor.logistics.tab.order') },
{ key: 'customer', label: t('monitor.logistics.tab.customer') },
{ key: 'track', label: t('monitor.logistics.tab.track') },
]);
const overview = ref<MonitorOverview | null>(null);
const overviewLoading = ref(true);
const opsDimension = ref<MonitorDimension>('day');
/** silent:切换日/周/月时不整页 Spin,避免闪白 */
async function loadOverview(opts?: { silent?: boolean }) {
const silent = opts?.silent ?? false;
if (!silent) overviewLoading.value = true;
try {
overview.value = await getMonitorOverview(opsDimension.value);
} catch {
if (!silent) overview.value = null;
} finally {
if (!silent) 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: 'bar',
barMaxWidth: 28,
data: revenueValues,
itemStyle: { color: revenueColor, borderRadius: [4, 4, 0, 0] },
},
{
name: t('monitor.logistics.series.grossProfit'),
type: 'bar',
barMaxWidth: 28,
data: grossValues,
itemStyle: { color: grossColor, borderRadius: [4, 4, 0, 0] },
},
],
},
{ 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) => {
// 面板用 v-show 常驻,切 Tab 只 resize / 补画,不 dispose,避免闪一下
nextTick(() => {
if (key === 'order') ensureOrderLineCharts();
if (key === 'track') ensureTrackCharts();
if (key === 'customer') ensureCustomerCharts();
nextTick(() => onResize());
});
},
{ immediate: true },
);
watch(opsDimension, () => {
void loadOverview({ silent: true });
});
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);
disposeOrderCharts();
disposeTrackCharts();
disposeCustomerCharts();
});
</script>
<style scoped lang="less">
.logistics-monitor-container {
--lm-well-bg: #ffffff;
--lm-well-shadow:
inset 0 0 0 1px rgba(15, 23, 42, 0.06),
inset 0 1px 4px rgba(15, 23, 42, 0.05);
--lm-well-shadow-deep:
inset 0 0 0 1px rgba(15, 23, 42, 0.08),
inset 0 2px 6px rgba(15, 23, 42, 0.07);
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;
}
.monitor-topbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 16px;
flex-shrink: 0;
margin-bottom: 12px;
min-height: 36px;
}
.monitor-topbar__spacer {
flex: 1 1 auto;
min-width: 8px;
}
.logistics-monitor-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text-1);
line-height: 32px;
white-space: nowrap;
}
.monitor-top-tabs {
flex-shrink: 0;
max-width: 100%;
}
.monitor-ops-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.monitor-ops-toolbar__label {
font-size: 13px;
color: var(--color-text-2);
white-space: nowrap;
}
.logistics-monitor-body {
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
:deep(.arco-spin) {
display: flex;
flex-direction: column;
height: 100%;
max-height: 100%;
}
:deep(.arco-spin-children) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}
.monitor-shell {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
}
.monitor-tab-panels {
flex: 1;
min-height: 0;
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.monitor-tab-panel {
min-width: 0;
}
.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: 84px;
height: 100%;
display: flex;
flex-direction: column;
border: none !important;
border-radius: 14px !important;
background: var(--lm-well-bg, #ffffff) !important;
box-shadow: var(--lm-well-shadow) !important;
transition: box-shadow 0.2s ease;
&:hover {
background: var(--lm-well-bg, #ffffff) !important;
box-shadow: var(--lm-well-shadow-deep) !important;
}
:deep(.arco-card-body) {
flex: 1;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: transparent;
}
}
/* 对齐工作台:左 icon+标题竖排,右数值;icon 无底框 */
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
}
.stat-item__main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
flex: 1;
}
.stat-item__meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.stat-item__avatar {
display: block;
width: 40px;
height: 40px;
object-fit: contain;
background: transparent;
border: none;
border-radius: 0;
}
.stat-item__avatar-icon {
display: inline-flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--app-primary-color);
background: transparent;
&.is-warning {
color: rgb(var(--orange-6));
}
&.is-online {
color: rgb(var(--cyan-6));
}
}
.stat-item__body {
display: flex;
align-items: baseline;
justify-content: flex-end;
flex-shrink: 0;
min-width: 0;
}
.stat-label {
max-width: 100%;
font-size: 12px;
line-height: 1.3;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-value {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -0.02em;
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;
align-items: flex-end;
justify-content: flex-end;
gap: 2px;
text-align: right;
}
.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%;
border: none !important;
border-radius: 12px !important;
background: var(--lm-well-bg, #ffffff) !important;
box-shadow: var(--lm-well-shadow, inset 0 2px 5px rgba(15, 23, 42, 0.08)) !important;
:deep(.arco-card-header),
:deep(.arco-card-body) {
background: transparent;
border-color: transparent;
}
}
.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>