284 lines
12 KiB
Vue
284 lines
12 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* F006 数据统计首页 (W3.x C P0.1)
|
|
* 替换老 /dashboard, 走 /sys/openapi/order/list 真 API
|
|
*
|
|
* 顶部 4 stat:
|
|
* - 订单总数 = data.total
|
|
* - 订单总额(元)= sum(totalAmount)
|
|
* - 今日订单 = 今日 createDate 计数
|
|
* - 今日交易额 = 今日 createDate 总和
|
|
*
|
|
* 图表 (用 order 真实数据):
|
|
* - 订单趋势线 (近 7 天按 createDate 分桶, 双 Y 轴: 订单数 + 交易额)
|
|
* - 订单状态分布 (5 状态环图)
|
|
*
|
|
* 失败回退: FALLBACK_*
|
|
*/
|
|
import { ref, onMounted, defineAsyncComponent } from 'vue'
|
|
import { httpGet } from '@/utils/request'
|
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
|
|
|
// 图表组件: echarts 在组件内动态 import, 独立 chunk 懒加载 (2026-08-18)
|
|
const TrendChart = defineAsyncComponent(() => import('./charts/EchartsTrendChart.vue'))
|
|
const StatusPie = defineAsyncComponent(() => import('./charts/EchartsStatusPie.vue'))
|
|
|
|
interface OrderItem {
|
|
orderId: string
|
|
totalAmount: number
|
|
goodsQuantity: number
|
|
status: number
|
|
createDate: string
|
|
}
|
|
interface OrderResp {
|
|
total: string | number
|
|
limit: number
|
|
page: number
|
|
list: OrderItem[]
|
|
}
|
|
interface Stat { label: string; value: string; delta: string; trend: 'up' | 'down'; icon: string }
|
|
|
|
const stats = ref<Stat[]>([
|
|
{ label: '订单总数', value: '--', delta: '', trend: 'up', icon: 'Document' },
|
|
{ label: '订单总额', value: '--', delta: '', trend: 'up', icon: 'Money' },
|
|
{ label: '今日订单', value: '--', delta: '0% 较昨日', trend: 'up', icon: 'Plus' },
|
|
{ label: '今日交易额', value: '--', delta: '0% 较昨日', trend: 'up', icon: 'Coin' }
|
|
])
|
|
const recentOrders = ref<any[]>([])
|
|
const trendData = ref<{ dates: string[]; counts: number[]; amounts: number[] }>({ dates: [], counts: [], amounts: [] })
|
|
const statusSlices = ref<{ name: string; value: number; color: string }[]>([])
|
|
const apiStatus = ref<'loading' | 'live' | 'fallback'>('loading')
|
|
// 订单状态映射 (老 renren: 1=待付款 2=已付款 3=已发货 4=已完成 5=已取消)
|
|
const ORDER_STATUS: Record<number, { label: string; type: string }> = {
|
|
1: { label: '待付款', type: 'warning' },
|
|
2: { label: '已付款', type: 'primary' },
|
|
3: { label: '已发货', type: 'info' },
|
|
4: { label: '已完成', type: 'success' },
|
|
5: { label: '已取消', type: 'danger' }
|
|
}
|
|
const recentColumns = [
|
|
{ prop: 'orderId', label: '订单号', width: 180 },
|
|
{ prop: 'amount', label: '金额', width: 120 },
|
|
{ prop: 'qty', label: '数量', width: 100 },
|
|
{ prop: 'status', label: '状态', width: 110, type: 'status' as const, statusMap: ORDER_STATUS },
|
|
{ prop: 'time', label: '时间', width: 140 }
|
|
]
|
|
|
|
// 工具: 毫秒时间戳 -> YYYY-MM-DD
|
|
function dateKey(ts: string | number | undefined | null): string {
|
|
if (!ts) return ''
|
|
const n = typeof ts === 'string' ? Number(ts) : ts
|
|
if (!Number.isFinite(n) || n <= 0) return ''
|
|
return new Date(n).toISOString().slice(0, 10)
|
|
}
|
|
function formatTime(ts: string | number | undefined | null): string {
|
|
if (!ts) return '--'
|
|
const n = typeof ts === 'string' ? Number(ts) : ts
|
|
if (!Number.isFinite(n) || n <= 0) return '--'
|
|
const d = new Date(n)
|
|
const pad = (x: number) => String(x).padStart(2, '0')
|
|
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
}
|
|
function calcDelta(today: number, yesterday: number): { delta: string; trend: 'up' | 'down' } {
|
|
if (yesterday === 0) return { delta: today > 0 ? '+100%' : '0%', trend: 'up' }
|
|
const pct = ((today - yesterday) / yesterday) * 100
|
|
return { delta: `${pct >= 0 ? '+' : ''}${pct.toFixed(1)}%`, trend: pct >= 0 ? 'up' : 'down' }
|
|
}
|
|
|
|
const FALLBACK_STATS: Stat[] = [
|
|
{ label: '订单总数', value: '1,356', delta: '+12.5%', trend: 'up', icon: 'Document' },
|
|
{ label: '订单总额', value: '¥3.28M', delta: '+8.3%', trend: 'up', icon: 'Money' },
|
|
{ label: '今日订单', value: '24', delta: '+3', trend: 'up', icon: 'Plus' },
|
|
{ label: '今日交易额', value: '¥128K', delta: '+15.2%', trend: 'up', icon: 'Coin' }
|
|
]
|
|
const FALLBACK_ORDERS = [
|
|
{ orderId: 'OP2059...768897', amount: '¥236,000', qty: '1000', status: '1', time: '07-04 18:30' },
|
|
{ orderId: 'OP2059...552833', amount: '¥12', qty: '1', status: '5', time: '07-04 18:25' },
|
|
{ orderId: 'OP2059...191169', amount: '¥1.22', qty: '0', status: '1', time: '07-04 18:14' }
|
|
]
|
|
const FALLBACK_TREND = {
|
|
dates: ['7/1', '7/2', '7/3', '7/4', '7/5', '7/6', '7/7'],
|
|
counts: [12, 18, 15, 22, 28, 24, 32],
|
|
amounts: [12000, 18000, 15000, 22000, 28000, 24000, 32000]
|
|
}
|
|
const FALLBACK_STATUS = [
|
|
{ name: '待付款', value: 28, color: '' },
|
|
{ name: '已付款', value: 14, color: '' },
|
|
{ name: '已发货', value: 86, color: '' },
|
|
{ name: '已完成', value: 192, color: '' },
|
|
{ name: '已取消', value: 6, color: '' }
|
|
]
|
|
|
|
async function loadRecentOrders(): Promise<any[]> {
|
|
try {
|
|
const resp = await httpGet<OrderResp>('/sys/openapi/order/list', { page: 1, limit: 5 })
|
|
if (!resp?.list) throw new Error('order list empty')
|
|
return resp.list.slice(0, 5).map((it) => ({
|
|
orderId: it.orderId || '--',
|
|
amount: `¥${Number(it.totalAmount || 0).toLocaleString()}`,
|
|
qty: String(it.goodsQuantity || 0),
|
|
status: String(it.status || 1),
|
|
time: formatTime(it.createDate)
|
|
}))
|
|
} catch {
|
|
return FALLBACK_ORDERS
|
|
}
|
|
}
|
|
|
|
async function loadAll(): Promise<OrderItem[]> {
|
|
try {
|
|
const resp = await httpGet<OrderResp>('/sys/openapi/order/list', { page: 1, limit: 500 })
|
|
if (!resp?.list || !Array.isArray(resp.list)) return []
|
|
return resp.list
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function buildTrend(items: OrderItem[]): { dates: string[]; counts: number[]; amounts: number[] } {
|
|
const buckets: Record<string, { count: number; amount: number }> = {}
|
|
items.forEach((it) => {
|
|
const d = dateKey(it.createDate)
|
|
if (!d) return
|
|
if (!buckets[d]) buckets[d] = { count: 0, amount: 0 }
|
|
buckets[d].count += 1
|
|
buckets[d].amount += Number(it.totalAmount || 0)
|
|
})
|
|
const sorted = Object.keys(buckets).sort()
|
|
const last7 = sorted.slice(-7)
|
|
return {
|
|
dates: last7.map(d => d.slice(5)),
|
|
counts: last7.map(d => buckets[d].count),
|
|
amounts: last7.map(d => Math.round(buckets[d].amount))
|
|
}
|
|
}
|
|
|
|
function buildStatusSlices(items: OrderItem[]): { name: string; value: number; color: string }[] {
|
|
const counts: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }
|
|
items.forEach(it => {
|
|
const k = it.status || 1
|
|
counts[k] = (counts[k] || 0) + 1
|
|
})
|
|
const colorVars = ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--red']
|
|
const labels = ['待付款', '已付款', '已发货', '已完成', '已取消']
|
|
return [1, 2, 3, 4, 5].map((s, i) => ({
|
|
name: labels[i],
|
|
value: counts[s],
|
|
color: `var(${colorVars[i]})`
|
|
}))
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const recent = await loadRecentOrders()
|
|
recentOrders.value = recent
|
|
|
|
const all = await loadAll()
|
|
const isLive = all.length > 0
|
|
|
|
if (isLive) {
|
|
const totalCount = all.length
|
|
const totalAmount = all.reduce((sum, it) => sum + Number(it.totalAmount || 0), 0)
|
|
const todayKey = new Date().toISOString().slice(0, 10)
|
|
const yesterdayKey = new Date(Date.now() - 86400000).toISOString().slice(0, 10)
|
|
const todayItems = all.filter(it => dateKey(it.createDate) === todayKey)
|
|
const yestItems = all.filter(it => dateKey(it.createDate) === yesterdayKey)
|
|
const todayCount = todayItems.length
|
|
const yestCount = yestItems.length
|
|
const todayAmt = todayItems.reduce((s, it) => s + Number(it.totalAmount || 0), 0)
|
|
const yestAmt = yestItems.reduce((s, it) => s + Number(it.totalAmount || 0), 0)
|
|
const d1 = calcDelta(todayCount, yestCount)
|
|
const d2 = calcDelta(todayAmt, yestAmt)
|
|
|
|
stats.value = [
|
|
{ label: '订单总数', value: totalCount.toLocaleString(), delta: '', trend: 'up', icon: 'Document' },
|
|
{ label: '订单总额', value: `¥${totalAmount.toLocaleString()}`, delta: '', trend: 'up', icon: 'Money' },
|
|
{ label: '今日订单', value: String(todayCount), delta: d1.delta + ' 较昨日', trend: d1.trend, icon: 'Plus' },
|
|
{ label: '今日交易额', value: `¥${todayAmt.toLocaleString()}`, delta: d2.delta + ' 较昨日', trend: d2.trend, icon: 'Coin' }
|
|
]
|
|
trendData.value = buildTrend(all)
|
|
statusSlices.value = buildStatusSlices(all)
|
|
} else {
|
|
stats.value = FALLBACK_STATS
|
|
trendData.value = FALLBACK_TREND
|
|
statusSlices.value = FALLBACK_STATUS
|
|
}
|
|
apiStatus.value = isLive ? 'live' : 'fallback'
|
|
|
|
})
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div class="rr-dashboard">
|
|
<div class="rr-page-header">
|
|
<div>
|
|
<h2 class="rr-page-title">数据统计</h2>
|
|
<p class="rr-page-sub">
|
|
2026 年 7 月 4 日 · 实时数据
|
|
<el-tag v-if="apiStatus !== 'loading'" size="small" :type="apiStatus === 'live' ? 'success' : 'info'" effect="light" class="rr-api-badge">
|
|
{{ apiStatus === 'live' ? '实时' : '示例数据' }}
|
|
</el-tag>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rr-stats-grid">
|
|
<div v-for="(s, i) in stats" :key="i" class="rr-stat">
|
|
<div class="rr-stat-head">
|
|
<el-icon class="rr-stat-icon"><component :is="s.icon" /></el-icon>
|
|
<span class="rr-stat-label">{{ s.label }}</span>
|
|
</div>
|
|
<div class="rr-stat-value">{{ s.value }}</div>
|
|
<div v-if="s.delta" class="rr-stat-delta" :class="s.trend">
|
|
<el-icon><component :is="s.trend === 'up' ? 'CaretTop' : 'CaretBottom'" /></el-icon>
|
|
{{ s.delta }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rr-chart-grid">
|
|
<div class="rr-card rr-chart-card">
|
|
<div class="rr-card-header">
|
|
<h3 class="rr-card-title">订单趋势(近 7 天)</h3>
|
|
</div>
|
|
<TrendChart :dates="trendData.dates" :counts="trendData.counts" :amounts="trendData.amounts" />
|
|
</div>
|
|
<div class="rr-card rr-chart-card">
|
|
<div class="rr-card-header">
|
|
<h3 class="rr-card-title">订单状态分布</h3>
|
|
</div>
|
|
<StatusPie :slices="statusSlices" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rr-card">
|
|
<div class="rr-card-header">
|
|
<h3 class="rr-card-title">近期订单</h3>
|
|
<el-button text type="primary" @click="$router.push('/order')">查看全部 →</el-button>
|
|
</div>
|
|
<DataTable :columns="recentColumns" :data="recentOrders" :show-pagination="false" empty-text="暂无订单" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.rr-dashboard { max-width: 1400px; }
|
|
.rr-page-header { margin-bottom: 20px; }
|
|
.rr-page-title { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
|
|
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
|
|
.rr-api-badge { margin-left: 8px; }
|
|
.rr-stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
|
|
.rr-stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 18px 20px; transition: all 0.15s; &:hover { border-color: var(--border-strong); box-shadow: var(--shadow); } }
|
|
.rr-stat-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
.rr-stat-icon { font-size: 16px; color: var(--ink-3); }
|
|
.rr-stat-label { font-size: 12px; color: var(--ink-3); font-weight: 500; }
|
|
.rr-stat-value { font-size: 28px; font-weight: 700; letter-spacing: -0.025em; color: var(--ink); margin-bottom: 6px; }
|
|
.rr-stat-delta { font-size: 11.5px; font-weight: 600; display: inline-flex; align-items: center; gap: 3px; &.up { color: var(--green); } &.down { color: var(--red); } }
|
|
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
|
.rr-card-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid var(--border); }
|
|
.rr-card-title { font-size: 15px; font-weight: 600; color: var(--ink); }
|
|
.rr-chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; }
|
|
.rr-chart-card { padding-bottom: 8px; }
|
|
.rr-chart { height: 280px; padding: 8px 4px; }
|
|
</style>
|