feat: 工作台与实时监控对接真实订单统计数据
- 工作台 KPI 与订单趋势接入 workplace-stats/trend API - 实时监控页接入 monitor/overview,含订单/客户/轨迹统计与图表 - 新增 monitor/workplace API 封装及 nginx/vite 代理配置 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
696adf7684
commit
16ae7d2192
@ -181,7 +181,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ============ Admin API 同源反代(/rbac /order /xhs … → api.nanafa.com/admin)============
|
# ============ Admin API 同源反代(/rbac /order /xhs … → api.nanafa.com/admin)============
|
||||||
location ~ ^/(rbac|order|customer|recharge|channel|system)/ {
|
location ~ ^/(rbac|order|monitor|customer|recharge|channel|system)/ {
|
||||||
rewrite ^/(.*)$ /admin/$1 break;
|
rewrite ^/(.*)$ /admin/$1 break;
|
||||||
proxy_pass http://api.nanafa.com;
|
proxy_pass http://api.nanafa.com;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@ -83,7 +83,7 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(rbac|order|customer|recharge|channel|system)/ {
|
location ~ ^/(rbac|order|monitor|customer|recharge|channel|system)/ {
|
||||||
rewrite ^/(.*)$ /admin/$1 break;
|
rewrite ^/(.*)$ /admin/$1 break;
|
||||||
proxy_pass http://api.nanafa.com;
|
proxy_pass http://api.nanafa.com;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
206
src/api/monitor.ts
Normal file
206
src/api/monitor.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { axiosInstance } from '@/api/rbac'
|
||||||
|
import { resolveAdminApiPath } from '@/utils/adminApi'
|
||||||
|
|
||||||
|
function monitorUrl(path: string): string {
|
||||||
|
return resolveAdminApiPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBusinessOk(body: unknown) {
|
||||||
|
if (body == null) throw new Error('空响应')
|
||||||
|
const payload = body as Record<string, unknown>
|
||||||
|
const code = payload.code ?? payload.Code
|
||||||
|
const ok =
|
||||||
|
code === undefined ||
|
||||||
|
code === null ||
|
||||||
|
code === 0 ||
|
||||||
|
code === 200 ||
|
||||||
|
code === '0' ||
|
||||||
|
code === '200' ||
|
||||||
|
payload.success === true ||
|
||||||
|
payload.Success === true
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(
|
||||||
|
String(payload.msg ?? payload.Msg ?? payload.message ?? payload.Message ?? '请求失败'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(raw: unknown): number {
|
||||||
|
const value = Number(raw ?? 0)
|
||||||
|
return Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function growth(raw: unknown): number | null {
|
||||||
|
if (raw === null || raw === undefined || raw === '') return null
|
||||||
|
const value = Number(raw)
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRegionTrend(raw: unknown) {
|
||||||
|
const bundle = (raw ?? {}) as Record<string, unknown>
|
||||||
|
const normalizePeriod = (periodRaw: unknown) => {
|
||||||
|
const period = (periodRaw ?? {}) as Record<string, unknown>
|
||||||
|
const labels = period.labels ?? period.Labels
|
||||||
|
const seriesRaw = period.series ?? period.Series
|
||||||
|
return {
|
||||||
|
labels: Array.isArray(labels) ? labels.map(String) : [],
|
||||||
|
series: Array.isArray(seriesRaw)
|
||||||
|
? seriesRaw.map((item) => {
|
||||||
|
const row = item as Record<string, unknown>
|
||||||
|
const values = row.values ?? row.Values
|
||||||
|
return {
|
||||||
|
region: String(row.region ?? row.Region ?? ''),
|
||||||
|
values: Array.isArray(values) ? values.map((v) => num(v)) : [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
week: normalizePeriod(bundle.week ?? bundle.Week),
|
||||||
|
month: normalizePeriod(bundle.month ?? bundle.Month),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorOverview {
|
||||||
|
order: {
|
||||||
|
todayCreated: number
|
||||||
|
todayCreatedGrowthPercent: number | null
|
||||||
|
total: number
|
||||||
|
totalMonthGrowthPercent: number | null
|
||||||
|
pendingPayment: number
|
||||||
|
pendingOnline: number
|
||||||
|
pendingSign: number
|
||||||
|
problematic: number
|
||||||
|
todayRevenue: number
|
||||||
|
todayRevenueGrowthPercent: number | null
|
||||||
|
}
|
||||||
|
customer: {
|
||||||
|
totalCustomers: number
|
||||||
|
totalCustomersMonthGrowthPercent: number | null
|
||||||
|
newCustomersToday: number
|
||||||
|
newCustomersGrowthPercent: number | null
|
||||||
|
onlineCustomers: number
|
||||||
|
inactiveLogin7d: number
|
||||||
|
inactiveLogin7dDelta: number
|
||||||
|
}
|
||||||
|
orderTrend7d: Array<{ label: string; count: number }>
|
||||||
|
revenueTrend7d: Array<{ label: string; revenue: number; grossProfit: number }>
|
||||||
|
regionOrderTrend: ReturnType<typeof normalizeRegionTrend>
|
||||||
|
regionReceivableTrend: ReturnType<typeof normalizeRegionTrend>
|
||||||
|
regionSignRates: Array<{ region: string; rate: number }>
|
||||||
|
trackRates: {
|
||||||
|
trackCompleteRate: number
|
||||||
|
trackTimelyRate: number
|
||||||
|
otdRate: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOverview(raw: Record<string, unknown>): MonitorOverview {
|
||||||
|
const order = (raw.order ?? raw.Order ?? {}) as Record<string, unknown>
|
||||||
|
const customer = (raw.customer ?? raw.Customer ?? {}) as Record<string, unknown>
|
||||||
|
const track = (raw.trackRates ?? raw.TrackRates ?? {}) as Record<string, unknown>
|
||||||
|
const orderTrendRaw = raw.orderTrend7d ?? raw.OrderTrend7d
|
||||||
|
const revenueTrendRaw = raw.revenueTrend7d ?? raw.RevenueTrend7d
|
||||||
|
const signRatesRaw = raw.regionSignRates ?? raw.RegionSignRates
|
||||||
|
|
||||||
|
return {
|
||||||
|
order: {
|
||||||
|
todayCreated: num(order.todayCreated ?? order.TodayCreated),
|
||||||
|
todayCreatedGrowthPercent: growth(order.todayCreatedGrowthPercent ?? order.TodayCreatedGrowthPercent),
|
||||||
|
total: num(order.total ?? order.Total),
|
||||||
|
totalMonthGrowthPercent: growth(order.totalMonthGrowthPercent ?? order.TotalMonthGrowthPercent),
|
||||||
|
pendingPayment: num(order.pendingPayment ?? order.PendingPayment),
|
||||||
|
pendingOnline: num(order.pendingOnline ?? order.PendingOnline),
|
||||||
|
pendingSign: num(order.pendingSign ?? order.PendingSign),
|
||||||
|
problematic: num(order.problematic ?? order.Problematic),
|
||||||
|
todayRevenue: num(order.todayRevenue ?? order.TodayRevenue),
|
||||||
|
todayRevenueGrowthPercent: growth(order.todayRevenueGrowthPercent ?? order.TodayRevenueGrowthPercent),
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
totalCustomers: num(customer.totalCustomers ?? customer.TotalCustomers),
|
||||||
|
totalCustomersMonthGrowthPercent: growth(
|
||||||
|
customer.totalCustomersMonthGrowthPercent ?? customer.TotalCustomersMonthGrowthPercent,
|
||||||
|
),
|
||||||
|
newCustomersToday: num(customer.newCustomersToday ?? customer.NewCustomersToday),
|
||||||
|
newCustomersGrowthPercent: growth(
|
||||||
|
customer.newCustomersGrowthPercent ?? customer.NewCustomersGrowthPercent,
|
||||||
|
),
|
||||||
|
onlineCustomers: num(customer.onlineCustomers ?? customer.OnlineCustomers),
|
||||||
|
inactiveLogin7d: num(customer.inactiveLogin7d ?? customer.InactiveLogin7d),
|
||||||
|
inactiveLogin7dDelta: num(customer.inactiveLogin7dDelta ?? customer.InactiveLogin7dDelta),
|
||||||
|
},
|
||||||
|
orderTrend7d: Array.isArray(orderTrendRaw)
|
||||||
|
? orderTrendRaw.map((item) => {
|
||||||
|
const row = item as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
label: String(row.label ?? row.Label ?? ''),
|
||||||
|
count: num(row.count ?? row.Count),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
revenueTrend7d: Array.isArray(revenueTrendRaw)
|
||||||
|
? revenueTrendRaw.map((item) => {
|
||||||
|
const row = item as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
label: String(row.label ?? row.Label ?? ''),
|
||||||
|
revenue: num(row.revenue ?? row.Revenue),
|
||||||
|
grossProfit: num(row.grossProfit ?? row.GrossProfit),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
regionOrderTrend: normalizeRegionTrend(raw.regionOrderTrend ?? raw.RegionOrderTrend),
|
||||||
|
regionReceivableTrend: normalizeRegionTrend(raw.regionReceivableTrend ?? raw.RegionReceivableTrend),
|
||||||
|
regionSignRates: Array.isArray(signRatesRaw)
|
||||||
|
? signRatesRaw.map((item) => {
|
||||||
|
const row = item as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
region: String(row.region ?? row.Region ?? ''),
|
||||||
|
rate: num(row.rate ?? row.Rate),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
trackRates: {
|
||||||
|
trackCompleteRate: num(track.trackCompleteRate ?? track.TrackCompleteRate),
|
||||||
|
trackTimelyRate: num(track.trackTimelyRate ?? track.TrackTimelyRate),
|
||||||
|
otdRate: num(track.otdRate ?? track.OtdRate),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实时监控概览 GET /monitor/overview */
|
||||||
|
export async function getMonitorOverview(): Promise<MonitorOverview> {
|
||||||
|
const res = await axiosInstance.get(monitorUrl('/monitor/overview'))
|
||||||
|
assertBusinessOk(res.data)
|
||||||
|
const payload = res.data as Record<string, unknown>
|
||||||
|
const data = (payload.data ?? payload.Data ?? payload) as Record<string, unknown>
|
||||||
|
return normalizeOverview(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMonitorNumber(value: number): string {
|
||||||
|
return Number(value || 0).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMonitorCurrency(value: number): string {
|
||||||
|
return `¥ ${Number(value || 0).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMonitorGrowth(value: number | null | undefined): {
|
||||||
|
show: boolean
|
||||||
|
up: boolean
|
||||||
|
text: string
|
||||||
|
} {
|
||||||
|
if (value == null || Number.isNaN(Number(value))) {
|
||||||
|
return { show: false, up: true, text: '' }
|
||||||
|
}
|
||||||
|
const numValue = Number(value)
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
up: numValue >= 0,
|
||||||
|
text: `${Math.abs(numValue).toFixed(1)}%`,
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/api/workplace.ts
Normal file
115
src/api/workplace.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { axiosInstance } from '@/api/rbac'
|
||||||
|
import { resolveAdminApiPath } from '@/utils/adminApi'
|
||||||
|
|
||||||
|
function orderUrl(path: string): string {
|
||||||
|
return resolveAdminApiPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBusinessOk(body: unknown) {
|
||||||
|
if (body == null) throw new Error('空响应')
|
||||||
|
const payload = body as Record<string, unknown>
|
||||||
|
const code = payload.code ?? payload.Code
|
||||||
|
const ok =
|
||||||
|
code === undefined ||
|
||||||
|
code === null ||
|
||||||
|
code === 0 ||
|
||||||
|
code === 200 ||
|
||||||
|
code === '0' ||
|
||||||
|
code === '200' ||
|
||||||
|
payload.success === true ||
|
||||||
|
payload.Success === true
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(
|
||||||
|
String(payload.msg ?? payload.Msg ?? payload.message ?? payload.Message ?? '请求失败'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapData<T>(body: unknown): T {
|
||||||
|
assertBusinessOk(body)
|
||||||
|
const payload = body as Record<string, unknown>
|
||||||
|
return (payload.data ?? payload.Data ?? body) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderWorkplaceStats {
|
||||||
|
total: number
|
||||||
|
counts: Record<string, number>
|
||||||
|
todayCreated: number
|
||||||
|
yesterdayCreated: number
|
||||||
|
dayOverDayGrowthPercent: number | null
|
||||||
|
pendingShip: number
|
||||||
|
shipped: number
|
||||||
|
pendingOnline: number
|
||||||
|
problematic: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderWorkplaceTrendPoint {
|
||||||
|
label: string
|
||||||
|
forecastCount: number
|
||||||
|
shippedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderWorkplaceTrend {
|
||||||
|
dimension: string
|
||||||
|
points: OrderWorkplaceTrendPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStats(raw: Record<string, unknown>): OrderWorkplaceStats {
|
||||||
|
const countsRaw = raw.counts ?? raw.Counts
|
||||||
|
return {
|
||||||
|
total: Number(raw.total ?? raw.Total ?? 0) || 0,
|
||||||
|
counts:
|
||||||
|
typeof countsRaw === 'object' && countsRaw !== null
|
||||||
|
? (countsRaw as Record<string, number>)
|
||||||
|
: {},
|
||||||
|
todayCreated: Number(raw.todayCreated ?? raw.TodayCreated ?? 0) || 0,
|
||||||
|
yesterdayCreated: Number(raw.yesterdayCreated ?? raw.YesterdayCreated ?? 0) || 0,
|
||||||
|
dayOverDayGrowthPercent:
|
||||||
|
raw.dayOverDayGrowthPercent ?? raw.DayOverDayGrowthPercent ?? null,
|
||||||
|
pendingShip: Number(raw.pendingShip ?? raw.PendingShip ?? 0) || 0,
|
||||||
|
shipped: Number(raw.shipped ?? raw.Shipped ?? 0) || 0,
|
||||||
|
pendingOnline: Number(raw.pendingOnline ?? raw.PendingOnline ?? 0) || 0,
|
||||||
|
problematic: Number(raw.problematic ?? raw.Problematic ?? 0) || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrend(raw: Record<string, unknown>): OrderWorkplaceTrend {
|
||||||
|
const pointsRaw = raw.points ?? raw.Points
|
||||||
|
const points = Array.isArray(pointsRaw)
|
||||||
|
? pointsRaw.map((item) => {
|
||||||
|
const row = item as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
label: String(row.label ?? row.Label ?? ''),
|
||||||
|
forecastCount: Number(row.forecastCount ?? row.ForecastCount ?? 0) || 0,
|
||||||
|
shippedCount: Number(row.shippedCount ?? row.ShippedCount ?? 0) || 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
dimension: String(raw.dimension ?? raw.Dimension ?? 'day'),
|
||||||
|
points,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台订单统计 GET /order/workplace-stats */
|
||||||
|
export async function getOrderWorkplaceStats(): Promise<OrderWorkplaceStats> {
|
||||||
|
const res = await axiosInstance.get(orderUrl('/order/workplace-stats'))
|
||||||
|
const data = unwrapData<Record<string, unknown>>(res.data)
|
||||||
|
return normalizeStats(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台订单趋势 GET /order/workplace-trend */
|
||||||
|
export async function getOrderWorkplaceTrend(params: {
|
||||||
|
dimension: 'day' | 'week' | 'month'
|
||||||
|
count?: number
|
||||||
|
}): Promise<OrderWorkplaceTrend> {
|
||||||
|
const res = await axiosInstance.get(orderUrl('/order/workplace-trend'), {
|
||||||
|
params: {
|
||||||
|
dimension: params.dimension,
|
||||||
|
count: params.count,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const data = unwrapData<Record<string, unknown>>(res.data)
|
||||||
|
return normalizeTrend(data)
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="logistics-monitor-body">
|
<div class="logistics-monitor-body">
|
||||||
|
<a-spin :loading="overviewLoading" style="width: 100%">
|
||||||
<a-tabs
|
<a-tabs
|
||||||
v-model:active-key="monitorTab"
|
v-model:active-key="monitorTab"
|
||||||
type="rounded"
|
type="rounded"
|
||||||
@ -15,140 +16,29 @@
|
|||||||
>
|
>
|
||||||
<a-tab-pane key="order" :title="$t('monitor.logistics.tab.order')">
|
<a-tab-pane key="order" :title="$t('monitor.logistics.tab.order')">
|
||||||
<a-row :gutter="[12, 12]" align="stretch" class="logistics-monitor-stats">
|
<a-row :gutter="[12, 12]" align="stretch" class="logistics-monitor-stats">
|
||||||
<a-col :xs="24" :sm="12" :md="12" :lg="12" :xl="6">
|
<a-col
|
||||||
|
v-for="card in orderStatCards"
|
||||||
|
: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%' }">
|
<a-card hoverable class="stat-card" :body-style="{ padding: '12px', height: '100%' }">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<div class="stat-item__head">
|
<div class="stat-item__head">
|
||||||
<div class="stat-label">{{ $t('monitor.logistics.stat.todayOrders') }}</div>
|
<div class="stat-label">{{ $t(card.labelKey) }}</div>
|
||||||
<div class="stat-item__icon stat-item__icon--primary">
|
|
||||||
<icon-calendar-clock />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">1,896</div>
|
|
||||||
<div class="stat-footer">
|
|
||||||
<div class="stat-trend up">
|
|
||||||
<icon-arrow-up />
|
|
||||||
12.5% {{ $t('monitor.logistics.vsYesterday') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :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('monitor.logistics.stat.historicalOrders') }}</div>
|
|
||||||
<div class="stat-item__icon stat-item__icon--primary">
|
|
||||||
<icon-history />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">128,460</div>
|
|
||||||
<div class="stat-footer">
|
|
||||||
<div class="stat-trend up">
|
|
||||||
<icon-arrow-up />
|
|
||||||
2.1% {{ $t('monitor.logistics.vsLastMonth') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :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('monitor.logistics.stat.pendingPayment') }}</div>
|
|
||||||
<div class="stat-item__icon stat-item__icon--warning">
|
|
||||||
<icon-stamp />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value stat-value--warning">56</div>
|
|
||||||
<div class="stat-footer">
|
|
||||||
<div class="stat-trend down">
|
|
||||||
<icon-arrow-down />
|
|
||||||
4.0% {{ $t('monitor.logistics.vsYesterday') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :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('monitor.logistics.stat.pendingOnline') }}</div>
|
|
||||||
<div class="stat-item__icon stat-item__icon--primary">
|
|
||||||
<icon-send />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">312</div>
|
|
||||||
<div class="stat-footer">
|
|
||||||
<div class="stat-trend up">
|
|
||||||
<icon-arrow-up />
|
|
||||||
3.6% {{ $t('monitor.logistics.vsYesterday') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :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('monitor.logistics.stat.pendingSign') }}</div>
|
|
||||||
<div class="stat-item__icon stat-item__icon--primary">
|
|
||||||
<icon-check-circle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">892</div>
|
|
||||||
<div class="stat-footer">
|
|
||||||
<div class="stat-trend down">
|
|
||||||
<icon-arrow-down />
|
|
||||||
1.2% {{ $t('monitor.logistics.vsYesterday') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :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('monitor.logistics.stat.orderExceptions') }}</div>
|
|
||||||
<div class="stat-item__icon stat-item__icon--warning">
|
|
||||||
<icon-exclamation-circle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value stat-value--warning">23</div>
|
|
||||||
<div class="stat-footer">
|
|
||||||
<div class="stat-trend down">
|
|
||||||
<icon-arrow-down />
|
|
||||||
8.2% {{ $t('monitor.logistics.vsYesterday') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :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('monitor.logistics.stat.todayRevenue') }}</div>
|
|
||||||
<div class="stat-item__icon stat-item__icon--primary">
|
<div class="stat-item__icon stat-item__icon--primary">
|
||||||
<icon-bar-chart />
|
<icon-bar-chart />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-value">¥ 126,390</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-footer">
|
||||||
<div class="stat-trend up">
|
<div class="stat-trend" :class="card.growth.up ? 'up' : 'down'">
|
||||||
<icon-arrow-up />
|
<icon-arrow-up v-if="card.growth.up" />
|
||||||
18.7% {{ $t('monitor.logistics.vsYesterday') }}
|
<icon-arrow-down v-else />
|
||||||
|
{{ card.growth.text }} {{ $t(card.compareKey) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -173,77 +63,41 @@
|
|||||||
|
|
||||||
<a-tab-pane key="customer" :title="$t('monitor.logistics.tab.customer')">
|
<a-tab-pane key="customer" :title="$t('monitor.logistics.tab.customer')">
|
||||||
<a-row :gutter="[12, 12]" align="stretch" class="logistics-monitor-stats">
|
<a-row :gutter="[12, 12]" align="stretch" class="logistics-monitor-stats">
|
||||||
<a-col :xs="24" :sm="12" :md="12" :lg="12" :xl="6">
|
<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%' }">
|
<a-card hoverable class="stat-card" :body-style="{ padding: '12px', height: '100%' }">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<div class="stat-item__head">
|
<div class="stat-item__head">
|
||||||
<div class="stat-label">{{ $t('monitor.logistics.stat.totalCustomers') }}</div>
|
<div class="stat-label">{{ $t(card.labelKey) }}</div>
|
||||||
<div class="stat-item__icon stat-item__icon--primary">
|
<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 />
|
<icon-user-group />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-value">12,480</div>
|
<div class="stat-value" :class="{ 'stat-value--warning': card.warning }">{{ card.value }}</div>
|
||||||
<div class="stat-footer">
|
<div class="stat-footer">
|
||||||
<div class="stat-trend up">
|
<div v-if="card.growth.show" class="stat-trend" :class="card.growth.up ? 'up' : 'down'">
|
||||||
<icon-arrow-up />
|
<icon-arrow-up v-if="card.growth.up" />
|
||||||
1.2% {{ $t('monitor.logistics.vsLastMonth') }}
|
<icon-arrow-down v-else />
|
||||||
|
{{ card.growth.text }} {{ $t(card.compareKey) }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="card.hintKey"
|
||||||
|
class="stat-hint"
|
||||||
|
:class="{ 'stat-hint--accent': card.hintAccent }"
|
||||||
|
>
|
||||||
|
{{ $t(card.hintKey) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-if="card.extraHintKey" class="stat-hint">{{ $t(card.extraHintKey) }}</div>
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :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('monitor.logistics.stat.newCustomers') }}</div>
|
|
||||||
<div class="stat-item__icon stat-item__icon--primary">
|
|
||||||
<icon-user-add />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">86</div>
|
|
||||||
<div class="stat-footer">
|
|
||||||
<div class="stat-trend up">
|
|
||||||
<icon-arrow-up />
|
|
||||||
6.2% {{ $t('monitor.logistics.vsYesterday') }}
|
|
||||||
</div>
|
|
||||||
<div class="stat-hint">{{ $t('monitor.logistics.hint.newCustomersToday') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :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('monitor.logistics.stat.onlineCustomers') }}</div>
|
|
||||||
<div class="stat-item__icon stat-item__icon--online">
|
|
||||||
<icon-thunderbolt />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">342</div>
|
|
||||||
<div class="stat-footer">
|
|
||||||
<div class="stat-hint stat-hint--accent">{{ $t('monitor.logistics.hint.onlineRealtime') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :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('monitor.logistics.stat.inactiveLogin7d') }}</div>
|
|
||||||
<div class="stat-item__icon stat-item__icon--warning">
|
|
||||||
<icon-clock-circle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value stat-value--warning">156</div>
|
|
||||||
<div class="stat-footer">
|
|
||||||
<div class="stat-hint">{{ $t('monitor.logistics.hint.inactiveLogin7d') }}</div>
|
|
||||||
<div class="stat-hint">{{ $t('monitor.logistics.hint.inactiveLogin7dCompare') }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
@ -365,6 +219,7 @@
|
|||||||
</a-row>
|
</a-row>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
|
</a-spin>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -375,11 +230,155 @@
|
|||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import { modifyAlpha } from 'zrender/lib/tool/color';
|
import { modifyAlpha } from 'zrender/lib/tool/color';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
getMonitorOverview,
|
||||||
|
formatMonitorNumber,
|
||||||
|
formatMonitorCurrency,
|
||||||
|
formatMonitorGrowth,
|
||||||
|
type MonitorOverview,
|
||||||
|
} from '@/api/monitor';
|
||||||
|
|
||||||
defineOptions({ name: 'Monitor' });
|
defineOptions({ name: 'Monitor' });
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const overview = ref<MonitorOverview | null>(null);
|
||||||
|
const overviewLoading = ref(true);
|
||||||
|
|
||||||
|
async function loadOverview() {
|
||||||
|
overviewLoading.value = true;
|
||||||
|
try {
|
||||||
|
overview.value = await getMonitorOverview();
|
||||||
|
} 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 orderStatCards = computed(() => {
|
||||||
|
const order = overview.value?.order;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'todayOrders',
|
||||||
|
labelKey: 'monitor.logistics.stat.todayOrders',
|
||||||
|
value: formatMonitorNumber(order?.todayCreated ?? 0),
|
||||||
|
growth: formatMonitorGrowth(order?.todayCreatedGrowthPercent),
|
||||||
|
compareKey: 'monitor.logistics.vsYesterday',
|
||||||
|
warning: false,
|
||||||
|
icon: 'calendar',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'historicalOrders',
|
||||||
|
labelKey: 'monitor.logistics.stat.historicalOrders',
|
||||||
|
value: formatMonitorNumber(order?.total ?? 0),
|
||||||
|
growth: formatMonitorGrowth(order?.totalMonthGrowthPercent),
|
||||||
|
compareKey: 'monitor.logistics.vsLastMonth',
|
||||||
|
warning: false,
|
||||||
|
icon: 'history',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pendingPayment',
|
||||||
|
labelKey: 'monitor.logistics.stat.pendingPayment',
|
||||||
|
value: formatMonitorNumber(order?.pendingPayment ?? 0),
|
||||||
|
growth: { show: false, up: true, text: '' },
|
||||||
|
compareKey: 'monitor.logistics.vsYesterday',
|
||||||
|
warning: true,
|
||||||
|
icon: 'stamp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pendingOnline',
|
||||||
|
labelKey: 'monitor.logistics.stat.pendingOnline',
|
||||||
|
value: formatMonitorNumber(order?.pendingOnline ?? 0),
|
||||||
|
growth: { show: false, up: true, text: '' },
|
||||||
|
compareKey: 'monitor.logistics.vsYesterday',
|
||||||
|
warning: false,
|
||||||
|
icon: 'send',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pendingSign',
|
||||||
|
labelKey: 'monitor.logistics.stat.pendingSign',
|
||||||
|
value: formatMonitorNumber(order?.pendingSign ?? 0),
|
||||||
|
growth: { show: false, up: true, text: '' },
|
||||||
|
compareKey: 'monitor.logistics.vsYesterday',
|
||||||
|
warning: false,
|
||||||
|
icon: 'check',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'orderExceptions',
|
||||||
|
labelKey: 'monitor.logistics.stat.orderExceptions',
|
||||||
|
value: formatMonitorNumber(order?.problematic ?? 0),
|
||||||
|
growth: { show: false, up: false, text: '' },
|
||||||
|
compareKey: 'monitor.logistics.vsYesterday',
|
||||||
|
warning: true,
|
||||||
|
icon: 'exception',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'todayRevenue',
|
||||||
|
labelKey: 'monitor.logistics.stat.todayRevenue',
|
||||||
|
value: formatMonitorCurrency(order?.todayRevenue ?? 0),
|
||||||
|
growth: formatMonitorGrowth(order?.todayRevenueGrowthPercent),
|
||||||
|
compareKey: 'monitor.logistics.vsYesterday',
|
||||||
|
warning: false,
|
||||||
|
icon: 'chart',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
const LINE_KEYS = ['us', 'eu', 'sea', 'au', 'jp', 'uk'] as const;
|
||||||
type LineKey = (typeof LINE_KEYS)[number];
|
type LineKey = (typeof LINE_KEYS)[number];
|
||||||
|
|
||||||
@ -449,7 +448,6 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function buildSignRateSeries(): { categories: string[]; values: number[] } {
|
function buildSignRateSeries(): { categories: string[]; values: number[] } {
|
||||||
const days = filterSignDays.value;
|
|
||||||
const lineKey = filterLine.value;
|
const lineKey = filterLine.value;
|
||||||
const keys: LineKey[] =
|
const keys: LineKey[] =
|
||||||
!lineKey || lineKey === 'all'
|
!lineKey || lineKey === 'all'
|
||||||
@ -458,16 +456,16 @@
|
|||||||
? [lineKey as LineKey]
|
? [lineKey as LineKey]
|
||||||
: [...LINE_KEYS];
|
: [...LINE_KEYS];
|
||||||
|
|
||||||
const dayFactor = 1 + (days - 7) * 0.0045;
|
const rateMap = new Map(
|
||||||
|
(overview.value?.regionSignRates ?? []).map((item) => [item.region, item.rate]),
|
||||||
|
);
|
||||||
|
|
||||||
const categories: string[] = [];
|
const categories: string[] = [];
|
||||||
const values: number[] = [];
|
const values: number[] = [];
|
||||||
|
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
const raw = baseSignRates[key] * dayFactor + (keys.length === 1 ? 0.3 : 0);
|
|
||||||
const v = Math.min(100, Math.max(0, Number(raw.toFixed(1))));
|
|
||||||
categories.push(t(`monitor.logistics.line.${key}`));
|
categories.push(t(`monitor.logistics.line.${key}`));
|
||||||
values.push(v);
|
values.push(Number(rateMap.get(key) ?? 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
return { categories, values };
|
return { categories, values };
|
||||||
@ -538,15 +536,17 @@
|
|||||||
return LINE_KEYS.includes(v as LineKey) ? [v as LineKey] : [...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 {
|
function averagePieMetric(metric: PieMetricKey): number {
|
||||||
const keys = getActiveLineKeysForPie();
|
const pass = getPiePassRate(metric);
|
||||||
const periodMul = piePeriod.value === 'month' ? 1.012 : 0.992;
|
return Math.min(99.5, Math.max(0, Number(pass.toFixed(1))));
|
||||||
let sum = 0;
|
|
||||||
keys.forEach((k) => {
|
|
||||||
sum += pieMetricBase[k][metric];
|
|
||||||
});
|
|
||||||
const raw = (sum / keys.length) * periodMul;
|
|
||||||
return Math.min(99.5, Math.max(70, Number(raw.toFixed(1))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPieChartOption(passColor: string, metric: PieMetricKey) {
|
function buildPieChartOption(passColor: string, metric: PieMetricKey) {
|
||||||
@ -670,35 +670,52 @@
|
|||||||
return orders.map((o) => Math.round(o * unitPrice));
|
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() {
|
function renderCustomerTrendCharts() {
|
||||||
const period = customerTrendPeriod.value;
|
const period = customerTrendPeriod.value;
|
||||||
const categories = buildCustomerPeriodLabels(period);
|
const orderTrend = getRegionTrendBundle('order', period);
|
||||||
|
const receivableTrend = getRegionTrendBundle('receivable', period);
|
||||||
|
const categories =
|
||||||
|
orderTrend?.labels?.length
|
||||||
|
? orderTrend.labels
|
||||||
|
: buildCustomerPeriodLabels(period);
|
||||||
const keys = getActiveCustomerLineKeys();
|
const keys = getActiveCustomerLineKeys();
|
||||||
const colors = getLineTrendColors();
|
const colors = getLineTrendColors();
|
||||||
const axisLine = readCssVar('--color-border-2', '#e5e6eb');
|
const axisLine = readCssVar('--color-border-2', '#e5e6eb');
|
||||||
const text2 = readCssVar('--color-text-2', '#86909c');
|
const text2 = readCssVar('--color-text-2', '#86909c');
|
||||||
|
|
||||||
const orderSeries = keys.map((key) => ({
|
const orderSeries = keys.map((key) => {
|
||||||
|
const matched = orderTrend?.series?.find((item) => item.region === key);
|
||||||
|
return {
|
||||||
name: t(`monitor.logistics.line.${key}`),
|
name: t(`monitor.logistics.line.${key}`),
|
||||||
type: 'line' as const,
|
type: 'line' as const,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'circle',
|
symbol: 'circle',
|
||||||
symbolSize: keys.length > 2 ? 4 : 6,
|
symbolSize: keys.length > 2 ? 4 : 6,
|
||||||
data: buildMockOrderSeries(key, period, categories.length),
|
data: matched?.values?.length ? matched.values : Array(categories.length).fill(0),
|
||||||
itemStyle: { color: colors[LINE_KEYS.indexOf(key) % colors.length] },
|
itemStyle: { color: colors[LINE_KEYS.indexOf(key) % colors.length] },
|
||||||
lineStyle: { width: keys.length > 2 ? 2 : 2.5 },
|
lineStyle: { width: keys.length > 2 ? 2 : 2.5 },
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const receivableSeries = keys.map((key) => ({
|
const receivableSeries = keys.map((key) => {
|
||||||
|
const matched = receivableTrend?.series?.find((item) => item.region === key);
|
||||||
|
return {
|
||||||
name: t(`monitor.logistics.line.${key}`),
|
name: t(`monitor.logistics.line.${key}`),
|
||||||
type: 'line' as const,
|
type: 'line' as const,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'circle',
|
symbol: 'circle',
|
||||||
symbolSize: keys.length > 2 ? 4 : 6,
|
symbolSize: keys.length > 2 ? 4 : 6,
|
||||||
data: buildMockReceivableSeries(key, period, categories.length),
|
data: matched?.values?.length ? matched.values : Array(categories.length).fill(0),
|
||||||
itemStyle: { color: colors[LINE_KEYS.indexOf(key) % colors.length] },
|
itemStyle: { color: colors[LINE_KEYS.indexOf(key) % colors.length] },
|
||||||
lineStyle: { width: keys.length > 2 ? 2 : 2.5 },
|
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 grid = { left: '3%', right: '3%', bottom: keys.length > 1 ? '14%' : '3%', top: '10%', containLabel: true };
|
||||||
|
|
||||||
@ -774,34 +791,37 @@
|
|||||||
function ensureOrderLineCharts() {
|
function ensureOrderLineCharts() {
|
||||||
const primary = readCssVar('--app-primary-color', '#2D8C8C');
|
const primary = readCssVar('--app-primary-color', '#2D8C8C');
|
||||||
const axisLine = readCssVar('--color-border-2', '#e5e6eb');
|
const axisLine = readCssVar('--color-border-2', '#e5e6eb');
|
||||||
const dayLabels = [
|
const orderTrend = overview.value?.orderTrend7d ?? [];
|
||||||
t('monitor.logistics.axis.d1'),
|
const revenueTrend = overview.value?.revenueTrend7d ?? [];
|
||||||
t('monitor.logistics.axis.d2'),
|
const dayLabels = orderTrend.length
|
||||||
t('monitor.logistics.axis.d3'),
|
? orderTrend.map((item) => item.label)
|
||||||
t('monitor.logistics.axis.d4'),
|
: revenueTrend.map((item) => item.label);
|
||||||
t('monitor.logistics.axis.d5'),
|
const orderValues = orderTrend.length
|
||||||
t('monitor.logistics.axis.d6'),
|
? orderTrend.map((item) => item.count)
|
||||||
t('monitor.logistics.axis.today'),
|
: [];
|
||||||
];
|
const revenueValues = revenueTrend.map((item) => item.revenue);
|
||||||
|
const grossValues = revenueTrend.map((item) => item.grossProfit);
|
||||||
|
|
||||||
if (orderChartRef.value) {
|
if (orderChartRef.value) {
|
||||||
if (!orderChartInstance) {
|
if (!orderChartInstance) {
|
||||||
orderChartInstance = echarts.init(orderChartRef.value);
|
orderChartInstance = echarts.init(orderChartRef.value);
|
||||||
orderChartInstance.setOption({
|
}
|
||||||
|
orderChartInstance.setOption(
|
||||||
|
{
|
||||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: dayLabels,
|
data: dayLabels.length ? dayLabels : [t('workplace.record.nodata')],
|
||||||
axisLine: { lineStyle: { color: axisLine } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
},
|
},
|
||||||
yAxis: { type: 'value', axisLine: { lineStyle: { color: axisLine } } },
|
yAxis: { type: 'value', minInterval: 1, axisLine: { lineStyle: { color: axisLine } } },
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: t('monitor.logistics.series.orders'),
|
name: t('monitor.logistics.series.orders'),
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
data: [1230, 1350, 1420, 1580, 1660, 1790, 1896],
|
data: orderValues,
|
||||||
itemStyle: { color: primary },
|
itemStyle: { color: primary },
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
@ -811,16 +831,19 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
},
|
||||||
}
|
{ notMerge: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profitChartRef.value) {
|
if (profitChartRef.value) {
|
||||||
if (!profitChartInstance) {
|
if (!profitChartInstance) {
|
||||||
profitChartInstance = echarts.init(profitChartRef.value);
|
profitChartInstance = echarts.init(profitChartRef.value);
|
||||||
|
}
|
||||||
const revenueColor = '#00B42A';
|
const revenueColor = '#00B42A';
|
||||||
const grossColor = '#FF7D00';
|
const grossColor = '#FF7D00';
|
||||||
profitChartInstance.setOption({
|
profitChartInstance.setOption(
|
||||||
|
{
|
||||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
legend: {
|
legend: {
|
||||||
@ -829,7 +852,7 @@
|
|||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: dayLabels,
|
data: dayLabels.length ? dayLabels : [t('workplace.record.nodata')],
|
||||||
axisLine: { lineStyle: { color: axisLine } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
},
|
},
|
||||||
yAxis: { type: 'value', axisLine: { lineStyle: { color: axisLine } } },
|
yAxis: { type: 'value', axisLine: { lineStyle: { color: axisLine } } },
|
||||||
@ -838,19 +861,20 @@
|
|||||||
name: t('monitor.logistics.series.revenue'),
|
name: t('monitor.logistics.series.revenue'),
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
data: [85600, 92300, 98700, 105200, 111500, 118900, 126390],
|
data: revenueValues,
|
||||||
itemStyle: { color: revenueColor },
|
itemStyle: { color: revenueColor },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t('monitor.logistics.series.grossProfit'),
|
name: t('monitor.logistics.series.grossProfit'),
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
data: [21400, 23000, 24600, 26200, 27800, 29600, 31500],
|
data: grossValues,
|
||||||
itemStyle: { color: grossColor },
|
itemStyle: { color: grossColor },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
},
|
||||||
}
|
{ notMerge: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
orderChartInstance?.resize();
|
orderChartInstance?.resize();
|
||||||
@ -873,6 +897,7 @@
|
|||||||
layoutResizeObserver = new ResizeObserver(() => onResize());
|
layoutResizeObserver = new ResizeObserver(() => onResize());
|
||||||
layoutResizeObserver.observe(logisticsMonitorRoot.value);
|
layoutResizeObserver.observe(logisticsMonitorRoot.value);
|
||||||
}
|
}
|
||||||
|
await loadOverview();
|
||||||
});
|
});
|
||||||
|
|
||||||
function disposeOrderCharts() {
|
function disposeOrderCharts() {
|
||||||
|
|||||||
@ -1,163 +1,184 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="order-state-panel">
|
<div class="order-state-panel">
|
||||||
<div class="panel-col">
|
<a-spin v-if="loading" class="order-state-panel__loading" />
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in statItems"
|
||||||
|
:key="item.key"
|
||||||
|
class="panel-col"
|
||||||
|
:class="{ 'panel-col--last': index === statItems.length - 1 }"
|
||||||
|
>
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-avatar :size="54" class="col-avatar">
|
<a-avatar :size="54" class="col-avatar">
|
||||||
<img
|
<img alt="" :src="item.icon" />
|
||||||
alt="avatar"
|
|
||||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/288b89194e657603ff40db39e8072640.svg~tplv-49unhts6dw-image.image"
|
|
||||||
/>
|
|
||||||
</a-avatar>
|
</a-avatar>
|
||||||
<a-statistic
|
<a-statistic
|
||||||
:title="$t('workplace.onlineContent')"
|
:title="t(item.titleKey)"
|
||||||
:value="373.5"
|
:value="item.value"
|
||||||
:precision="1"
|
:precision="item.precision"
|
||||||
show-group-separator
|
show-group-separator
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template v-if="item.suffixKey" #suffix>
|
||||||
W+ <span class="unit">{{ $t('workplace.pecs') }}</span>
|
<span class="unit">{{ t(item.suffixKey) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.showTrend" #suffix>
|
||||||
|
% <icon-caret-up v-if="item.trendUp" class="up-icon" />
|
||||||
|
<icon-caret-down v-else class="down-icon" />
|
||||||
</template>
|
</template>
|
||||||
</a-statistic>
|
</a-statistic>
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-col">
|
|
||||||
<a-space>
|
|
||||||
<a-avatar :size="54" class="col-avatar">
|
|
||||||
<img
|
|
||||||
alt="avatar"
|
|
||||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/fdc66b07224cdf18843c6076c2587eb5.svg~tplv-49unhts6dw-image.image"
|
|
||||||
/>
|
|
||||||
</a-avatar>
|
|
||||||
<a-statistic
|
|
||||||
:title="$t('workplace.putIn')"
|
|
||||||
:value="368"
|
|
||||||
show-group-separator
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<span class="unit">{{ $t('workplace.pecs') }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
</a-statistic>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
<div class="panel-col">
|
|
||||||
<a-space>
|
|
||||||
<a-avatar :size="54" class="col-avatar">
|
|
||||||
<img
|
|
||||||
alt="avatar"
|
|
||||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/77d74c9a245adeae1ec7fb5d4539738d.svg~tplv-49unhts6dw-image.image"
|
|
||||||
/>
|
|
||||||
</a-avatar>
|
|
||||||
<a-statistic
|
|
||||||
:title="$t('workplace.newDay')"
|
|
||||||
:value="8874"
|
|
||||||
show-group-separator
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<span class="unit">{{ $t('workplace.pecs') }}</span>
|
|
||||||
</template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
<div class="panel-col">
|
|
||||||
<a-space>
|
|
||||||
<a-avatar :size="54" class="col-avatar">
|
|
||||||
<img
|
|
||||||
alt="avatar"
|
|
||||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/288b89194e657603ff40db39e8072640.svg~tplv-49unhts6dw-image.image"
|
|
||||||
/>
|
|
||||||
</a-avatar>
|
|
||||||
<a-statistic
|
|
||||||
:title="$t('workplace.pendingOnline')"
|
|
||||||
:value="156"
|
|
||||||
show-group-separator
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<span class="unit">{{ $t('workplace.pecs') }}</span>
|
|
||||||
</template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
<div class="panel-col">
|
|
||||||
<a-space>
|
|
||||||
<a-avatar :size="54" class="col-avatar">
|
|
||||||
<img
|
|
||||||
alt="avatar"
|
|
||||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/c8b36e26d2b9bb5dbf9b74dd6d7345af.svg~tplv-49unhts6dw-image.image"
|
|
||||||
/>
|
|
||||||
</a-avatar>
|
|
||||||
<a-statistic
|
|
||||||
:title="$t('workplace.problematic')"
|
|
||||||
:value="12"
|
|
||||||
show-group-separator
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<span class="unit">{{ $t('workplace.pecs') }}</span>
|
|
||||||
</template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
<div class="panel-col panel-col--last">
|
|
||||||
<a-space>
|
|
||||||
<a-avatar :size="54" class="col-avatar">
|
|
||||||
<img
|
|
||||||
alt="avatar"
|
|
||||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/c8b36e26d2b9bb5dbf9b74dd6d7345af.svg~tplv-49unhts6dw-image.image"
|
|
||||||
/>
|
|
||||||
</a-avatar>
|
|
||||||
<a-statistic
|
|
||||||
:title="$t('workplace.newFromYesterday')"
|
|
||||||
:value="2.8"
|
|
||||||
:precision="1"
|
|
||||||
>
|
|
||||||
<template #suffix> % <icon-caret-up class="up-icon" /> </template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
<a-divider class="panel-border" />
|
<a-divider class="panel-border" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup></script>
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { IconCaretDown, IconCaretUp } from '@arco-design/web-vue/es/icon';
|
||||||
|
import { getOrderWorkplaceStats, type OrderWorkplaceStats } from '@/api/workplace';
|
||||||
|
|
||||||
|
const STAT_ICONS = [
|
||||||
|
'//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/288b89194e657603ff40db39e8072640.svg~tplv-49unhts6dw-image.image',
|
||||||
|
'//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/fdc66b07224cdf18843c6076c2587eb5.svg~tplv-49unhts6dw-image.image',
|
||||||
|
'//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/77d74c9a245adeae1ec7fb5d4539738d.svg~tplv-49unhts6dw-image.image',
|
||||||
|
'//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/288b89194e657603ff40db39e8072640.svg~tplv-49unhts6dw-image.image',
|
||||||
|
'//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/c8b36e26d2b9bb5dbf9b74dd6d7345af.svg~tplv-49unhts6dw-image.image',
|
||||||
|
'//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/c8b36e26d2b9bb5dbf9b74dd6d7345af.svg~tplv-49unhts6dw-image.image',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const loading = ref(true);
|
||||||
|
const stats = ref<OrderWorkplaceStats | null>(null);
|
||||||
|
|
||||||
|
const statItems = computed(() => {
|
||||||
|
const data = stats.value;
|
||||||
|
const growth = data?.dayOverDayGrowthPercent;
|
||||||
|
const growthValue = growth == null ? 0 : Math.abs(Number(growth));
|
||||||
|
const growthUp = growth == null ? true : Number(growth) >= 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'todayCreated',
|
||||||
|
titleKey: 'workplace.onlineContent',
|
||||||
|
value: data?.todayCreated ?? 0,
|
||||||
|
precision: 0,
|
||||||
|
suffixKey: 'workplace.pecs',
|
||||||
|
icon: STAT_ICONS[0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pendingShip',
|
||||||
|
titleKey: 'workplace.putIn',
|
||||||
|
value: data?.pendingShip ?? 0,
|
||||||
|
precision: 0,
|
||||||
|
suffixKey: 'workplace.pecs',
|
||||||
|
icon: STAT_ICONS[1],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'shipped',
|
||||||
|
titleKey: 'workplace.newDay',
|
||||||
|
value: data?.shipped ?? 0,
|
||||||
|
precision: 0,
|
||||||
|
suffixKey: 'workplace.pecs',
|
||||||
|
icon: STAT_ICONS[2],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pendingOnline',
|
||||||
|
titleKey: 'workplace.pendingOnline',
|
||||||
|
value: data?.pendingOnline ?? 0,
|
||||||
|
precision: 0,
|
||||||
|
suffixKey: 'workplace.pecs',
|
||||||
|
icon: STAT_ICONS[3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'problematic',
|
||||||
|
titleKey: 'workplace.problematic',
|
||||||
|
value: data?.problematic ?? 0,
|
||||||
|
precision: 0,
|
||||||
|
suffixKey: 'workplace.pecs',
|
||||||
|
icon: STAT_ICONS[4],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'growth',
|
||||||
|
titleKey: 'workplace.newFromYesterday',
|
||||||
|
value: growthValue,
|
||||||
|
precision: 1,
|
||||||
|
showTrend: true,
|
||||||
|
trendUp: growthUp,
|
||||||
|
icon: STAT_ICONS[5],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
stats.value = await getOrderWorkplaceStats();
|
||||||
|
} catch {
|
||||||
|
stats.value = null;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadStats();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.order-state-panel {
|
.order-state-panel {
|
||||||
|
position: relative;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
padding: 16px 20px 0;
|
padding: 16px 20px 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 16px 0;
|
gap: 16px 0;
|
||||||
}
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-col {
|
.order-state-panel__loading {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-col {
|
||||||
padding-left: 43px;
|
padding-left: 43px;
|
||||||
border-right: 1px solid rgb(var(--gray-2));
|
border-right: 1px solid rgb(var(--gray-2));
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-col--last {
|
.panel-col--last {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-avatar {
|
.col-avatar {
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
background-color: var(--color-fill-2);
|
background-color: var(--color-fill-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.up-icon {
|
.up-icon {
|
||||||
color: rgb(var(--red-6));
|
color: rgb(var(--red-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit {
|
.down-icon {
|
||||||
|
color: rgb(var(--green-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
color: rgb(var(--gray-8));
|
color: rgb(var(--gray-8));
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.panel-border) {
|
:deep(.panel-border) {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.order-state-panel {
|
.order-state-panel {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@ -165,5 +186,5 @@
|
|||||||
.panel-col {
|
.panel-col {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -7,97 +7,81 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
订单分析
|
{{ t('workplace.orderAnalysis.title') }}
|
||||||
</template>
|
</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<div class="chart-controls">
|
<div class="chart-controls">
|
||||||
<a-radio-group v-model="dimension" type="button" size="small" @change="handleDimensionChange">
|
<a-radio-group v-model="dimension" type="button" size="small" @change="handleDimensionChange">
|
||||||
<a-radio value="day">日</a-radio>
|
<a-radio value="day">{{ t('workplace.orderAnalysis.day') }}</a-radio>
|
||||||
<a-radio value="week">周</a-radio>
|
<a-radio value="week">{{ t('workplace.orderAnalysis.week') }}</a-radio>
|
||||||
<a-radio value="month">月</a-radio>
|
<a-radio value="month">{{ t('workplace.orderAnalysis.month') }}</a-radio>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<Chart class="order-analysis-chart" height="360px" :option="chartOption" />
|
<a-spin v-if="loading" class="order-analysis-loading" />
|
||||||
|
<Chart v-else class="order-analysis-chart" height="360px" :option="chartOption" />
|
||||||
</a-card>
|
</a-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import Chart from '@/components/chart/index.vue';
|
import Chart from '@/components/chart/index.vue';
|
||||||
import { useAppStore } from '@/store';
|
import { useAppStore } from '@/store';
|
||||||
import dayjs from 'dayjs';
|
import { getOrderWorkplaceTrend } from '@/api/workplace';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const dimension = ref<'day' | 'week' | 'month'>('day');
|
const dimension = ref<'day' | 'week' | 'month'>('day');
|
||||||
const days = ref(7); // 默认7天
|
const pointCount = ref(7);
|
||||||
|
const loading = ref(true);
|
||||||
// 生成日期数据
|
const xAxisLabels = ref<string[]>([]);
|
||||||
const generateDateLabels = (dim: 'day' | 'week' | 'month', count: number) => {
|
|
||||||
const labels: string[] = [];
|
|
||||||
const today = dayjs();
|
|
||||||
|
|
||||||
if (dim === 'day') {
|
|
||||||
for (let i = count - 1; i >= 0; i--) {
|
|
||||||
labels.push(today.subtract(i, 'day').format('MM-DD'));
|
|
||||||
}
|
|
||||||
} else if (dim === 'week') {
|
|
||||||
for (let i = count - 1; i >= 0; i--) {
|
|
||||||
const weekStart = today.subtract(i, 'week').startOf('week');
|
|
||||||
labels.push(`第${weekStart.format('ww')}周`);
|
|
||||||
}
|
|
||||||
} else if (dim === 'month') {
|
|
||||||
for (let i = count - 1; i >= 0; i--) {
|
|
||||||
labels.push(today.subtract(i, 'month').format('YYYY-MM'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return labels;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 生成模拟数据
|
|
||||||
const generateMockData = (count: number) => {
|
|
||||||
return Array.from({ length: count }, () => Math.floor(Math.random() * 500) + 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const xAxisLabels = computed(() => generateDateLabels(dimension.value, days.value));
|
|
||||||
const forecastData = ref<number[]>([]);
|
const forecastData = ref<number[]>([]);
|
||||||
const shippedData = ref<number[]>([]);
|
const shippedData = ref<number[]>([]);
|
||||||
|
|
||||||
const loadData = () => {
|
async function loadData() {
|
||||||
const count = days.value;
|
loading.value = true;
|
||||||
forecastData.value = generateMockData(count);
|
try {
|
||||||
shippedData.value = generateMockData(count);
|
const trend = await getOrderWorkplaceTrend({
|
||||||
};
|
dimension: dimension.value,
|
||||||
|
count: pointCount.value,
|
||||||
|
});
|
||||||
|
xAxisLabels.value = trend.points.map((item) => item.label);
|
||||||
|
forecastData.value = trend.points.map((item) => item.forecastCount);
|
||||||
|
shippedData.value = trend.points.map((item) => item.shippedCount);
|
||||||
|
} catch {
|
||||||
|
xAxisLabels.value = [];
|
||||||
|
forecastData.value = [];
|
||||||
|
shippedData.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDimensionChange = () => {
|
const handleDimensionChange = () => {
|
||||||
// 根据维度调整天数
|
|
||||||
if (dimension.value === 'week') {
|
if (dimension.value === 'week') {
|
||||||
days.value = 4; // 4周
|
pointCount.value = 4;
|
||||||
} else if (dimension.value === 'month') {
|
} else if (dimension.value === 'month') {
|
||||||
days.value = 6; // 6个月
|
pointCount.value = 6;
|
||||||
} else {
|
} else {
|
||||||
days.value = 7; // 7天
|
pointCount.value = 7;
|
||||||
}
|
}
|
||||||
loadData();
|
void loadData();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用 computed 来创建响应式的图表配置
|
|
||||||
const chartOption = computed(() => {
|
const chartOption = computed(() => {
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const isDark = appStore.theme === 'dark';
|
const isDark = appStore.theme === 'dark';
|
||||||
|
|
||||||
// 确保数据已准备好
|
|
||||||
const labels = xAxisLabels.value || [];
|
const labels = xAxisLabels.value || [];
|
||||||
const forecast = forecastData.value || [];
|
const forecast = forecastData.value || [];
|
||||||
const shipped = shippedData.value || [];
|
const shipped = shippedData.value || [];
|
||||||
|
|
||||||
// 如果数据为空,返回最小有效配置
|
|
||||||
if (labels.length === 0 || forecast.length === 0 || shipped.length === 0) {
|
if (labels.length === 0 || forecast.length === 0 || shipped.length === 0) {
|
||||||
return {
|
return {
|
||||||
grid: { left: '3%', right: '4%', top: '20', bottom: '40' },
|
grid: { left: '3%', right: '4%', top: '20', bottom: '40' },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: labels.length > 0 ? labels : ['暂无数据'],
|
data: [t('workplace.record.nodata')],
|
||||||
boundaryGap: false,
|
boundaryGap: false,
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
@ -146,6 +130,7 @@ const chartOption = computed(() => {
|
|||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
|
minInterval: 1,
|
||||||
axisLine: {
|
axisLine: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
@ -160,7 +145,7 @@ const chartOption = computed(() => {
|
|||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '预报订单',
|
name: t('workplace.orderAnalysis.forecast'),
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
data: forecast,
|
data: forecast,
|
||||||
@ -194,7 +179,7 @@ const chartOption = computed(() => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '发货订单',
|
name: t('workplace.orderAnalysis.shipped'),
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
data: shipped,
|
data: shipped,
|
||||||
@ -232,7 +217,7 @@ const chartOption = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData();
|
void loadData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -243,8 +228,14 @@ onMounted(() => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-analysis-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
.order-analysis-chart {
|
.order-analysis-chart {
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@ -4,12 +4,18 @@ export default {
|
|||||||
'workplace.balance': 'Balance (CNY)',
|
'workplace.balance': 'Balance (CNY)',
|
||||||
'workplace.order.pending': 'Pending',
|
'workplace.order.pending': 'Pending',
|
||||||
'workplace.order.pendingRenewal': 'Renewal Order',
|
'workplace.order.pendingRenewal': 'Renewal Order',
|
||||||
'workplace.onlineContent': 'Online Content',
|
'workplace.onlineContent': 'New Orders Today',
|
||||||
'workplace.putIn': 'Put In',
|
'workplace.putIn': 'Pending Shipment',
|
||||||
'workplace.pendingOnline': 'Pending Online',
|
'workplace.pendingOnline': 'Pending Online',
|
||||||
'workplace.newDay': 'Daily Additional Comments',
|
'workplace.newDay': 'Shipped',
|
||||||
'workplace.problematic': 'Problematic',
|
'workplace.problematic': 'Problematic',
|
||||||
'workplace.newFromYesterday': 'New From Yesterday',
|
'workplace.newFromYesterday': 'Day-over-Day Growth',
|
||||||
|
'workplace.orderAnalysis.title': 'Order Analysis',
|
||||||
|
'workplace.orderAnalysis.day': 'Day',
|
||||||
|
'workplace.orderAnalysis.week': 'Week',
|
||||||
|
'workplace.orderAnalysis.month': 'Month',
|
||||||
|
'workplace.orderAnalysis.forecast': 'New Orders',
|
||||||
|
'workplace.orderAnalysis.shipped': 'Shipped Orders',
|
||||||
'workplace.minute': 'Min',
|
'workplace.minute': 'Min',
|
||||||
'workplace.docs': 'Documents',
|
'workplace.docs': 'Documents',
|
||||||
'workplace.docs.productOverview': 'Product Overview',
|
'workplace.docs.productOverview': 'Product Overview',
|
||||||
@ -36,7 +42,7 @@ export default {
|
|||||||
'workplace.popularContent.image': 'image',
|
'workplace.popularContent.image': 'image',
|
||||||
'workplace.popularContent.video': 'video',
|
'workplace.popularContent.video': 'video',
|
||||||
'workplace.categoriesPercent': 'Categories Percent',
|
'workplace.categoriesPercent': 'Categories Percent',
|
||||||
'workplace.pecs': 'pecs',
|
'workplace.pecs': 'orders',
|
||||||
'workplace.noticePopup.markRead': 'Mark as read',
|
'workplace.noticePopup.markRead': 'Mark as read',
|
||||||
'workplace.noticePopup.close': 'Close',
|
'workplace.noticePopup.close': 'Close',
|
||||||
'workplace.noticePopup.readFailed': 'Could not mark as read. Please try again.',
|
'workplace.noticePopup.readFailed': 'Could not mark as read. Please try again.',
|
||||||
|
|||||||
@ -4,12 +4,18 @@ export default {
|
|||||||
'workplace.balance': '余额(元)',
|
'workplace.balance': '余额(元)',
|
||||||
'workplace.order.pending': '待支付',
|
'workplace.order.pending': '待支付',
|
||||||
'workplace.order.pendingRenewal': '待续费订单',
|
'workplace.order.pendingRenewal': '待续费订单',
|
||||||
'workplace.onlineContent': '今日预报订单',
|
'workplace.onlineContent': '今日新增订单',
|
||||||
'workplace.putIn': '待发货',
|
'workplace.putIn': '待发货',
|
||||||
'workplace.pendingOnline': '待上网',
|
'workplace.pendingOnline': '待上网',
|
||||||
'workplace.newDay': '已发货',
|
'workplace.newDay': '已发货',
|
||||||
'workplace.problematic': '问题件',
|
'workplace.problematic': '问题件',
|
||||||
'workplace.newFromYesterday': '较昨日新增',
|
'workplace.newFromYesterday': '较昨日新增',
|
||||||
|
'workplace.orderAnalysis.title': '订单分析',
|
||||||
|
'workplace.orderAnalysis.day': '日',
|
||||||
|
'workplace.orderAnalysis.week': '周',
|
||||||
|
'workplace.orderAnalysis.month': '月',
|
||||||
|
'workplace.orderAnalysis.forecast': '新增订单',
|
||||||
|
'workplace.orderAnalysis.shipped': '发货订单',
|
||||||
'workplace.minute': '分钟',
|
'workplace.minute': '分钟',
|
||||||
'workplace.docs': '帮助文档',
|
'workplace.docs': '帮助文档',
|
||||||
'workplace.docs.productOverview': '产品概要',
|
'workplace.docs.productOverview': '产品概要',
|
||||||
|
|||||||
@ -45,6 +45,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
'/monitor': {
|
||||||
|
target: 'http://api.nanafa.com/admin',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
'/customer': {
|
'/customer': {
|
||||||
target: 'http://api.nanafa.com/admin',
|
target: 'http://api.nanafa.com/admin',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user