F006 W3.xC + W5.x.x+1/3/4/5 + W6 + W7 完整批次 (17 commits) #1

Merged
direv merged 24 commits from develop into main 2026-07-05 13:07:34 +08:00
2 changed files with 558 additions and 3 deletions
Showing only changes of commit 552baacee9 - Show all commits

View File

@ -0,0 +1,357 @@
<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, watch, nextTick } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import * as echarts from 'echarts/core'
import { LineChart, PieChart } from 'echarts/charts'
import { TooltipComponent, GridComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { useThemeStore } from '@/stores/theme'
echarts.use([LineChart, PieChart, TooltipComponent, GridComponent, LegendComponent, CanvasRenderer])
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')
const trendChartEl = ref<HTMLDivElement | null>(null)
const statusChartEl = ref<HTMLDivElement | null>(null)
let trendChart: echarts.ECharts | null = null
let statusChart: echarts.ECharts | null = null
const themeStore = useThemeStore()
// 订单状态映射 (老 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'
await nextTick()
renderTrend()
renderStatus()
})
// ECharts 渲染
function getBrand(): string { return getComputedStyle(document.body).getPropertyValue('--brand').trim() || '#2563EB' }
function getInk(): string { return getComputedStyle(document.body).getPropertyValue('--ink-3').trim() || '#64748B' }
function getBorder(): string { return getComputedStyle(document.body).getPropertyValue('--border').trim() || '#E2E8F0' }
function getSurface(): string { return getComputedStyle(document.body).getPropertyValue('--surface').trim() || '#FFFFFF' }
function getChartColors(): string[] {
return ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--red'].map(v =>
getComputedStyle(document.body).getPropertyValue(v).trim()
)
}
function renderTrend() {
if (!trendChartEl.value) return
if (!trendChart) trendChart = echarts.init(trendChartEl.value)
const ink = getInk(), border = getBorder(), surface = getSurface(), brand = getBrand()
const chart2 = getComputedStyle(document.body).getPropertyValue('--chart-2').trim() || '#7C3AED'
trendChart.setOption({
grid: { left: 50, right: 50, top: 40, bottom: 30 },
tooltip: { trigger: 'axis', backgroundColor: surface, borderColor: border, textStyle: { color: ink } },
legend: { top: 0, textStyle: { color: ink } },
xAxis: { type: 'category', data: trendData.value.dates, axisLine: { lineStyle: { color: border } }, axisLabel: { color: ink } },
yAxis: [
{ type: 'value', name: '订单数', position: 'left', splitLine: { lineStyle: { color: border } }, axisLabel: { color: ink } },
{ type: 'value', name: '交易额', position: 'right', splitLine: { show: false }, axisLabel: { color: ink, formatter: (v: number) => v >= 1000 ? `${(v/1000).toFixed(0)}K` : String(v) } }
],
series: [
{ name: '订单数', type: 'line', data: trendData.value.counts, smooth: true, itemStyle: { color: brand }, lineStyle: { color: brand, width: 2 }, areaStyle: { color: brand, opacity: 0.15 } },
{ name: '交易额', type: 'line', yAxisIndex: 1, data: trendData.value.amounts, smooth: true, itemStyle: { color: chart2 }, lineStyle: { color: chart2, width: 2 } }
]
})
}
function renderStatus() {
if (!statusChartEl.value) return
if (!statusChart) statusChart = echarts.init(statusChartEl.value)
const ink = getInk(), border = getBorder(), surface = getSurface()
const colors = getChartColors()
const total = statusSlices.value.reduce((a, b) => a + b.value, 0)
statusChart.setOption({
tooltip: { trigger: 'item', backgroundColor: surface, borderColor: border, textStyle: { color: ink } },
legend: { bottom: 0, textStyle: { color: ink } },
series: [{
type: 'pie', radius: ['58%', '80%'], center: ['50%', '45%'],
avoidLabelOverlap: true,
label: { show: false }, labelLine: { show: false },
data: statusSlices.value.map((s, i) => ({ name: s.name, value: s.value, itemStyle: { color: colors[i] } })),
emphasis: { label: { show: true, fontSize: 14, fontWeight: 600, color: ink } }
}],
graphic: total > 0 ? [
{ type: 'text', left: 'center', top: '38%', style: { text: '总计', fontSize: 10, fill: ink, opacity: 0.5 } },
{ type: 'text', left: 'center', top: '45%', style: { text: String(total), fontSize: 24, fontWeight: 700, fill: ink } }
] : []
})
}
watch([() => trendData.value, () => statusSlices.value, () => themeStore.current], async () => {
await nextTick()
renderTrend()
renderStatus()
})
window.addEventListener('resize', () => { trendChart?.resize(); statusChart?.resize() })
</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>
<div ref="trendChartEl" class="rr-chart"></div>
</div>
<div class="rr-card rr-chart-card">
<div class="rr-card-header">
<h3 class="rr-card-title">订单状态分布</h3>
</div>
<div ref="statusChartEl" class="rr-chart"></div>
</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: 22px; font-weight: 600; 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>

View File

@ -1,6 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
/**
* 路由结构
* - /login 公开
* - / + /dashboard 实际页面
* - /inquiry 询价列表
* - /inquiry/:id 询价详情
* - /:module/:action 通配占位(覆盖菜单映射的 70 个 url,避免 404)
* - /:pathMatch(.*)* 兜底跳首页
*/
const routes: RouteRecordRaw[] = [
{
path: '/login',
@ -8,11 +17,200 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/modules/auth/LoginView.vue'),
meta: { title: '登录', public: true }
},
// W3.x C P0.1: 数据统计首页 (生产环境一级模块, 走 order 真 API)
{
path: '/',
name: 'Dashboard',
component: () => import('@/modules/dashboard/DashboardView.vue'),
meta: { title: '数据看板', icon: 'dashboard' }
name: 'Statistics',
component: () => import('@/modules/statistics/StatisticsView.vue'),
meta: { title: '数据统计', icon: 'DataAnalysis' }
},
{
path: '/dashboard',
redirect: '/'
},
// statistics/index 走通配时, 仍走 StatisticsView
{
path: '/statistics/index',
name: 'StatisticsIndex',
component: () => import('@/modules/statistics/StatisticsView.vue'),
meta: { title: '数据统计', icon: 'DataAnalysis' }
},
{
path: '/inquiry',
name: 'InquiryList',
component: () => import('@/modules/inquiry/InquiryListView.vue'),
meta: { title: '询价管理' }
},
{
path: '/inquiry/:id',
name: 'InquiryDetail',
component: () => import('@/modules/inquiry/InquiryDetailView.vue'),
meta: { title: '询价详情' }
},
// W3.2 订单管理(具体路由必须在 /:module/:action 之前,否则被通配拦截)
{
path: '/order',
name: 'OrderList',
component: () => import('@/modules/order/OrderListView.vue'),
meta: { title: '订单管理' }
},
{
path: '/order/:id',
name: 'OrderDetail',
component: () => import('@/modules/order/OrderDetailView.vue'),
meta: { title: '订单详情' }
},
// W4.19 业务模块(必须在通配占位之前)
{
path: '/user/enterprise',
name: 'UserEnterpriseList',
component: () => import('@/modules/user/EnterpriseListView.vue'),
meta: { title: '企业管理' }
},
{
path: '/user/finance',
name: 'UserFinanceList',
component: () => import('@/modules/user/FinanceListView.vue'),
meta: { title: '财务管理' }
},
{
path: '/supplier/activity',
name: 'SupplierActivityList',
component: () => import('@/modules/supplier/ActivityListView.vue'),
meta: { title: '营销活动' }
},
// W4.1 集成管理 - 应用列表(具体路由必须在通配占位之前)
// 详情是 dialog 弹窗不是路由页(老 vue app-list.vue 结构)
{
path: '/openapi/app',
name: 'OpenAppList',
component: () => import('@/modules/openapi/OpenAppListView.vue'),
meta: { title: '应用列表' }
},
// W4.2 集成管理 - 公司账号管理
{
path: '/openapi/company',
name: 'OpenCompanyAccount',
component: () => import('@/modules/openapi/CompanyAccountListView.vue'),
meta: { title: '公司账号管理' }
},
// W4.3 集成管理 - 审计日志
{
path: '/openapi/audit-log',
name: 'OpenAuditLog',
component: () => import('@/modules/openapi/AuditLogListView.vue'),
meta: { title: '审计日志' }
},
// W4.5 用户管理(后台用户)
{
path: '/sys/user',
name: 'SysUserList',
component: () => import('@/modules/sys/UserListView.vue'),
meta: { title: '用户管理' }
},
// W4.6 角色管理
{
path: '/sys/role',
name: 'SysRoleList',
component: () => import('@/modules/sys/RoleListView.vue'),
meta: { title: '角色管理' }
},
// W4.7 参数管理(对应老 devtools/param, 后端 /sys/params/*)
{
path: '/sys/params',
name: 'SysParamsList',
component: () => import('@/modules/sys/ParamsListView.vue'),
meta: { title: '参数管理' }
},
// W4.14 sys 模块补全:部门/菜单/日志
{
path: '/sys/dept',
name: 'SysDeptList',
component: () => import('@/modules/sys/DeptListView.vue'),
meta: { title: '部门管理' }
},
{
path: '/sys/menu',
name: 'SysMenuList',
component: () => import('@/modules/sys/MenuListView.vue'),
meta: { title: '菜单管理' }
},
{
path: '/sys/log-login',
name: 'SysLogLogin',
component: () => import('@/modules/sys/LogLoginView.vue'),
meta: { title: '登录日志' }
},
{
path: '/sys/log-operation',
name: 'SysLogOperation',
component: () => import('@/modules/sys/LogOperationView.vue'),
meta: { title: '操作日志' }
},
// W4.16 sys 模块补全:岗位/错误日志/定时任务/角色组/文件
{
path: '/sys/post',
name: 'SysPostList',
component: () => import('@/modules/sys/PostListView.vue'),
meta: { title: '岗位管理' }
},
{
path: '/sys/log-error',
name: 'SysLogError',
component: () => import('@/modules/sys/LogErrorView.vue'),
meta: { title: '错误日志' }
},
{
path: '/sys/schedule',
name: 'SysScheduleList',
component: () => import('@/modules/sys/ScheduleListView.vue'),
meta: { title: '定时任务' }
},
{
path: '/sys/role-group',
name: 'SysRoleGroupList',
component: () => import('@/modules/sys/RoleGroupListView.vue'),
meta: { title: '角色组管理' }
},
{
path: '/sys/file',
name: 'SysFileList',
component: () => import('@/modules/sys/FileListView.vue'),
meta: { title: '文件管理' }
},
// W4.18 字典/通知/商品
{
path: '/sys/dict-data',
name: 'SysDictDataList',
component: () => import('@/modules/sys/DictDataListView.vue'),
meta: { title: '字典数据' }
},
{
path: '/sys/dict-type',
name: 'SysDictTypeList',
component: () => import('@/modules/sys/DictTypeListView.vue'),
meta: { title: '字典类型' }
},
{
path: '/sys/notice',
name: 'SysNoticeList',
component: () => import('@/modules/sys/NoticeListView.vue'),
meta: { title: '通知公告' }
},
{
path: '/sys/product',
name: 'SysProductList',
component: () => import('@/modules/sys/ProductListView.vue'),
meta: { title: '商品管理' }
},
// W3.1 通用通配占位 — 覆盖菜单 70 个 url(两层路径,如 /admin/user/finance/recharge)
// 注意:必须放在具体路由后面,否则会拦截 /inquiry /login
// 使用 ?:action 让 action 可选(单层路径如 /sys/user 也匹配),不加 (.*)* 避免贪婪匹配抢静态路由
{
path: '/:module/:action?',
name: 'Placeholder',
component: () => import('@/modules/placeholder/PlaceholderView.vue'),
meta: { title: '即将上线' }
},
{
path: '/:pathMatch(.*)*',