735 lines
19 KiB
Plaintext
735 lines
19 KiB
Plaintext
<template>
|
||
<view class="page">
|
||
<view class="container">
|
||
<!-- 顶部头部(白底,横排按钮) -->
|
||
<view class="topbar">
|
||
<view class="topbar-left">
|
||
<text class="title">数据分析中心</text>
|
||
<text class="subtitle">最后更新:{{ lastUpdateTime }}</text>
|
||
</view>
|
||
<view class="topbar-right">
|
||
<view class="icon-btn" @click="refreshAll">刷新</view>
|
||
<view class="icon-btn primary" @click="exportReport">导出</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- KPI:宽屏 4列,窄屏 2列 -->
|
||
<view class="kpi-grid">
|
||
<view class="kpi-card">
|
||
<text class="kpi-label">实时 GMV</text>
|
||
<text class="kpi-value">¥{{ formatMoney(realTime.gmv) }}</text>
|
||
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.gmv_growth) }}</text>
|
||
</view>
|
||
<view class="kpi-card">
|
||
<text class="kpi-label">实时订单</text>
|
||
<text class="kpi-value">{{ formatInt(realTime.orders) }}</text>
|
||
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.order_growth) }}</text>
|
||
</view>
|
||
<view class="kpi-card">
|
||
<text class="kpi-label">在线用户</text>
|
||
<text class="kpi-value">{{ formatInt(realTime.online_users) }}</text>
|
||
<text class="kpi-meta">最近 5 分钟</text>
|
||
</view>
|
||
<view class="kpi-card">
|
||
<text class="kpi-label">转化率</text>
|
||
<text class="kpi-value">{{ formatPct(realTime.conversion_rate) }}</text>
|
||
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.conversion_growth) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 时间维度:横排 -->
|
||
<view class="tabs">
|
||
<view
|
||
v-for="p in timePeriods"
|
||
:key="p.value"
|
||
class="tab"
|
||
:class="{ active: selectedPeriod === p.value }"
|
||
@click="selectPeriod(p.value)"
|
||
>
|
||
{{ p.label }}
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 核心趋势:占满横向(柱+折 组合图) -->
|
||
<view class="card card-full">
|
||
<view class="card-head">
|
||
<text class="card-title">核心趋势(GMV / 订单数)</text>
|
||
<text class="card-desc">{{ selectedPeriodText }} · 柱:GMV(元) · 线:订单数</text>
|
||
</view>
|
||
|
||
<AnalyticsComboChart
|
||
:xLabels="trend.x"
|
||
:gmv="trend.gmv"
|
||
:orders="trend.orders"
|
||
:height="320"
|
||
/>
|
||
</view>
|
||
|
||
<!-- 用户结构:横向占满 -->
|
||
<view class="card fullwide">
|
||
<view class="card-head">
|
||
<text class="card-title">用户结构(环形图)</text>
|
||
<text class="card-desc">未消费 / 首购 / 复购 / 回流</text>
|
||
</view>
|
||
<EChartsView class="chart-box" :option="userSegmentOption" />
|
||
</view>
|
||
|
||
<!-- 洞察区:宽屏左右分栏,窄屏自动上下 -->
|
||
<view class="insights-row">
|
||
<!-- 左侧:大图表 -->
|
||
<view class="insights-left card">
|
||
<view class="card-head">
|
||
<text class="card-title">流量来源(条形)</text>
|
||
<text class="card-desc">占比%</text>
|
||
</view>
|
||
<EChartsView class="chart-box" :option="trafficBarOption" />
|
||
</view>
|
||
|
||
<!-- 右侧:两个小卡片纵向堆叠 -->
|
||
<view class="insights-right">
|
||
<view class="card">
|
||
<view class="card-head">
|
||
<text class="card-title">热销商品 TOP</text>
|
||
<text class="card-desc">按销量</text>
|
||
</view>
|
||
<view class="rank-list">
|
||
<view v-for="p in topProducts" :key="p.id" class="rank-item">
|
||
<text class="rank-no">{{ p.rank }}</text>
|
||
<text class="rank-name">{{ p.name }}</text>
|
||
<text class="rank-val">{{ p.sales }} 件</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="card">
|
||
<view class="card-head">
|
||
<text class="card-title">商家排行 TOP</text>
|
||
<text class="card-desc">按 GMV</text>
|
||
</view>
|
||
<view class="rank-list">
|
||
<view v-for="m in topMerchants" :key="m.id" class="rank-item">
|
||
<text class="rank-no">{{ m.rank }}</text>
|
||
<text class="rank-name">{{ m.name }}</text>
|
||
<view class="rank-right">
|
||
<text class="rank-val">¥{{ formatMoney(m.sales) }}</text>
|
||
<text class="chip" :class="m.growth >= 0 ? 'pos' : 'neg'">
|
||
{{ m.growth >= 0 ? '+' : '' }}{{ m.growth }}%
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 留白 -->
|
||
<view style="height: 24px;"></view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script lang="uts">
|
||
import supa from '@/components/supadb/aksupainstance.uts'
|
||
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
|
||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||
|
||
type TimePeriod = { value: string; label: string }
|
||
type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
|
||
type SegmentItem = { name: string; value: number }
|
||
type TrafficItem = { name: string; value: number }
|
||
|
||
export default {
|
||
components: {
|
||
AnalyticsComboChart,
|
||
EChartsView
|
||
},
|
||
data() {
|
||
return {
|
||
lastUpdateTime: '',
|
||
selectedPeriod: '7d',
|
||
timePeriods: [
|
||
{ value: '7d', label: '7天' },
|
||
{ value: '30d', label: '30天' },
|
||
{ value: '90d', label: '90天' },
|
||
{ value: '1y', label: '1年' }
|
||
] as Array<TimePeriod>,
|
||
|
||
realTime: {
|
||
gmv: 0,
|
||
gmv_growth: 0,
|
||
orders: 0,
|
||
order_growth: 0,
|
||
online_users: 0,
|
||
conversion_rate: 0,
|
||
conversion_growth: 0
|
||
},
|
||
|
||
trend: {
|
||
x: [] as Array<string>,
|
||
gmv: [] as Array<number>,
|
||
orders: [] as Array<number>
|
||
} as TrendData,
|
||
|
||
userSegments: [
|
||
{ name: '未消费用户', value: 72 },
|
||
{ name: '消费一次用户', value: 14 },
|
||
{ name: '留存客户', value: 9 },
|
||
{ name: '回流客户', value: 5 }
|
||
] as Array<SegmentItem>,
|
||
|
||
trafficSources: [
|
||
{ name: '直接访问', value: 45 },
|
||
{ name: '搜索引擎', value: 28 },
|
||
{ name: '社交媒体', value: 18 },
|
||
{ name: '广告推广', value: 9 }
|
||
] as Array<TrafficItem>,
|
||
|
||
topProducts: [
|
||
{ id: '1', rank: 1, name: '苹果 iPhone 15', sales: 580 },
|
||
{ id: '2', rank: 2, name: '华为 Mate 60', sales: 456 },
|
||
{ id: '3', rank: 3, name: '小米 14 Pro', sales: 389 }
|
||
],
|
||
|
||
topMerchants: [
|
||
{ id: '1', rank: 1, name: '华强北电子城', sales: 580000, growth: 15.6 },
|
||
{ id: '2', rank: 2, name: '时尚服装馆', sales: 456000, growth: 12.3 },
|
||
{ id: '3', rank: 3, name: '美食天地', sales: 389000, growth: -2.1 }
|
||
],
|
||
|
||
// 图表 options
|
||
trafficBarOption: {} as any,
|
||
userSegmentOption: {} as any
|
||
}
|
||
},
|
||
|
||
computed: {
|
||
selectedPeriodText(): string {
|
||
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
|
||
return p ? p.label : '7天'
|
||
}
|
||
},
|
||
|
||
watch: {
|
||
trafficSources: {
|
||
handler() {
|
||
this.buildChartOptions()
|
||
},
|
||
deep: true
|
||
},
|
||
userSegments: {
|
||
handler() {
|
||
this.buildChartOptions()
|
||
},
|
||
deep: true
|
||
}
|
||
},
|
||
|
||
onLoad() {
|
||
this.refreshAll()
|
||
this.buildChartOptions()
|
||
},
|
||
|
||
methods: {
|
||
async refreshAll() {
|
||
this.updateTime()
|
||
await this.loadRealTime()
|
||
this.mockTrend() // 先给你可视化效果(你再替换成真实查询)
|
||
this.updateTime()
|
||
uni.showToast({ title: '已刷新', icon: 'success' })
|
||
},
|
||
|
||
selectPeriod(p: string) {
|
||
this.selectedPeriod = p
|
||
this.mockTrend()
|
||
},
|
||
|
||
updateTime() {
|
||
const now = new Date()
|
||
const hh = now.getHours().toString().padStart(2, '0')
|
||
const mm = now.getMinutes().toString().padStart(2, '0')
|
||
this.lastUpdateTime = `${hh}:${mm}`
|
||
},
|
||
|
||
// 组合趋势:先模拟(你可以换成 supa 查询 + 聚合)
|
||
mockTrend() {
|
||
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 12
|
||
const x: Array<string> = []
|
||
const gmv: Array<number> = []
|
||
const orders: Array<number> = []
|
||
for (let i = 0; i < days; i++) {
|
||
x.push(days === 12 ? `${i + 1}月` : `${i + 1}`)
|
||
// 假数据:你替换为真实聚合
|
||
const base = 80000 + i * 1200 + Math.round(Math.random() * 8000)
|
||
gmv.push(base)
|
||
orders.push(120 + i * 2 + Math.round(Math.random() * 30))
|
||
}
|
||
this.trend = { x, gmv, orders }
|
||
},
|
||
|
||
// 实时指标:核心是"强制数值化 + 兜底",避免对象直接渲染
|
||
async loadRealTime() {
|
||
try {
|
||
// 你可以把你原来的 supa 查询搬进来,这里只做"数值兜底示例"
|
||
// 注意:任何可能不是 number 的结果,都用 Number(...) 和 isFinite 处理
|
||
const safe = (v: any): number => {
|
||
const n = Number(v)
|
||
return isFinite(n) ? n : 0
|
||
}
|
||
|
||
const now = new Date()
|
||
const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||
const todayISO = today0.toISOString()
|
||
|
||
const ySame = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||
const y0 = new Date(ySame.getFullYear(), ySame.getMonth(), ySame.getDate())
|
||
const y0ISO = y0.toISOString()
|
||
const ySameISO = ySame.toISOString()
|
||
|
||
// 今日订单(已支付 status=2)
|
||
const { data: todayOrders } = await supa
|
||
.from('orders')
|
||
.select('total_amount, created_at, user_id')
|
||
.gte('created_at', todayISO)
|
||
.eq('status', 2)
|
||
|
||
// 昨日同时间段
|
||
const { data: yOrders } = await supa
|
||
.from('orders')
|
||
.select('total_amount, created_at, user_id')
|
||
.gte('created_at', y0ISO)
|
||
.lte('created_at', ySameISO)
|
||
.eq('status', 2)
|
||
|
||
let gmvT = 0
|
||
if (todayOrders != null) {
|
||
for (let i = 0; i < todayOrders.length; i++) {
|
||
gmvT += safe(todayOrders[i].total_amount)
|
||
}
|
||
}
|
||
let gmvY = 0
|
||
if (yOrders != null) {
|
||
for (let i = 0; i < yOrders.length; i++) {
|
||
gmvY += safe(yOrders[i].total_amount)
|
||
}
|
||
}
|
||
const gmvGrowth = gmvY > 0 ? ((gmvT - gmvY) / gmvY * 100) : (gmvT > 0 ? 100 : 0)
|
||
|
||
const ordersT = todayOrders != null ? todayOrders.length : 0
|
||
const ordersY = yOrders != null ? yOrders.length : 0
|
||
const orderGrowth = ordersY > 0 ? ((ordersT - ordersY) / ordersY * 100) : (ordersT > 0 ? 100 : 0)
|
||
|
||
// 在线用户(最近 5 分钟)
|
||
const fiveAgoISO = new Date(now.getTime() - 5 * 60 * 1000).toISOString()
|
||
const { count: onlineCnt } = await supa
|
||
.from('user_sessions')
|
||
.select('*', { count: 'exact', head: true })
|
||
.gte('last_active_at', fiveAgoISO)
|
||
.eq('is_active', true)
|
||
|
||
let online = safe(onlineCnt)
|
||
|
||
// 转化率(下单用户数 / 访问用户数)
|
||
const uniq: Record<string, boolean> = {}
|
||
if (todayOrders != null) {
|
||
for (let i = 0; i < todayOrders.length; i++) {
|
||
const uid = todayOrders[i].user_id
|
||
if (uid) uniq[uid] = true
|
||
}
|
||
}
|
||
const orderUsers = Object.keys(uniq).length
|
||
|
||
const { count: visitorsToday } = await supa
|
||
.from('user_sessions')
|
||
.select('*', { count: 'exact', head: true })
|
||
.gte('created_at', todayISO)
|
||
|
||
const vT = safe(visitorsToday)
|
||
const convT = vT > 0 ? (orderUsers / vT) * 100 : 0
|
||
|
||
// 昨日同时间段转化率
|
||
const uniqY: Record<string, boolean> = {}
|
||
if (yOrders != null) {
|
||
for (let i = 0; i < yOrders.length; i++) {
|
||
const uid = yOrders[i].user_id
|
||
if (uid) uniqY[uid] = true
|
||
}
|
||
}
|
||
const { count: visitorsY } = await supa
|
||
.from('user_sessions')
|
||
.select('*', { count: 'exact', head: true })
|
||
.gte('created_at', y0ISO)
|
||
.lte('created_at', ySameISO)
|
||
|
||
const vY = safe(visitorsY)
|
||
const convY = vY > 0 ? (Object.keys(uniqY).length / vY) * 100 : 0
|
||
const convGrowth = convY > 0 ? ((convT - convY) / convY * 100) : (convT > 0 ? 100 : 0)
|
||
|
||
this.realTime = {
|
||
gmv: Math.round(gmvT),
|
||
gmv_growth: safe(gmvGrowth),
|
||
orders: ordersT,
|
||
order_growth: safe(orderGrowth),
|
||
online_users: online,
|
||
conversion_rate: safe(convT),
|
||
conversion_growth: safe(convGrowth)
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
}
|
||
},
|
||
|
||
exportReport() {
|
||
uni.showActionSheet({
|
||
itemList: ['导出Excel', '导出PDF', '导出图片'],
|
||
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
|
||
})
|
||
},
|
||
|
||
formatInt(n: number): string {
|
||
const v = isFinite(n) ? Math.round(n) : 0
|
||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||
return v.toString()
|
||
},
|
||
|
||
formatMoney(n: number): string {
|
||
const v = isFinite(n) ? n : 0
|
||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||
return v.toFixed(0)
|
||
},
|
||
|
||
formatPct(n: number): string {
|
||
const v = isFinite(n) ? n : 0
|
||
const sign = v > 0 ? '+' : ''
|
||
return `${sign}${v.toFixed(1)}%`
|
||
},
|
||
|
||
// 构建图表 options
|
||
buildChartOptions() {
|
||
// 流量来源条形图
|
||
const trafficX = this.trafficSources.map((it) => it.name)
|
||
const trafficY = this.trafficSources.map((it) => {
|
||
const n = Number(it.value)
|
||
return isFinite(n) ? n : 0
|
||
})
|
||
this.trafficBarOption = {
|
||
grid: { left: 80, right: 24, top: 18, bottom: 18 },
|
||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
|
||
yAxis: { type: 'category', data: trafficX, axisTick: { show: false }, axisLabel: { color: 'rgba(0,0,0,0.65)' } },
|
||
series: [{ type: 'bar', data: trafficY, barWidth: 14, itemStyle: { borderRadius: 6 } }]
|
||
}
|
||
|
||
// 用户结构环形图
|
||
const segmentData = this.userSegments.map((it) => ({
|
||
name: it.name,
|
||
value: (() => {
|
||
const n = Number(it.value)
|
||
return isFinite(n) ? n : 0
|
||
})()
|
||
}))
|
||
this.userSegmentOption = {
|
||
tooltip: { trigger: 'item' },
|
||
legend: { left: 0, bottom: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 12 } },
|
||
series: [
|
||
{
|
||
type: 'pie',
|
||
radius: ['55%', '75%'],
|
||
center: ['50%', '45%'],
|
||
avoidLabelOverlap: true,
|
||
label: { show: true, formatter: '{b}\n{d}%' },
|
||
labelLine: { length: 10, length2: 10 },
|
||
data: segmentData
|
||
}
|
||
]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
/* 页面:白底 + 宽屏居中 + 自适应 */
|
||
/* 说明:uni-app 的 rpx 会随屏宽缩放,宽屏 H5 建议用 max-width 控制内容宽度。 */
|
||
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #f6f7fb;
|
||
}
|
||
|
||
.container {
|
||
width: 100%;
|
||
max-width: 1280px;
|
||
margin: 0 auto;
|
||
padding: 16px 16px 28px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* 顶部 */
|
||
.topbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-end;
|
||
gap: 12px;
|
||
padding: 14px 14px;
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(0,0,0,0.06);
|
||
}
|
||
|
||
.topbar-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.title {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #111;
|
||
}
|
||
|
||
.subtitle {
|
||
font-size: 12px;
|
||
color: rgba(0,0,0,0.55);
|
||
}
|
||
|
||
.topbar-right {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.icon-btn {
|
||
padding: 8px 12px;
|
||
border-radius: 10px;
|
||
background: #f3f4f6;
|
||
color: #111;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.icon-btn.primary {
|
||
background: #111;
|
||
color: #fff;
|
||
}
|
||
|
||
/* KPI:默认 2列,宽屏 4列 */
|
||
.kpi-grid {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.kpi-card {
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(0,0,0,0.06);
|
||
padding: 14px;
|
||
box-sizing: border-box;
|
||
width: calc(50% - 6px);
|
||
}
|
||
|
||
.kpi-label {
|
||
font-size: 12px;
|
||
color: rgba(0,0,0,0.55);
|
||
}
|
||
|
||
.kpi-value {
|
||
margin-top: 8px;
|
||
font-size: 22px;
|
||
font-weight: 800;
|
||
color: #111;
|
||
}
|
||
|
||
.kpi-meta {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: rgba(0,0,0,0.55);
|
||
}
|
||
|
||
/* 时间维度 tabs 横排 */
|
||
.tabs {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 8px;
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(0,0,0,0.06);
|
||
overflow-x: auto;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
}
|
||
|
||
.tab {
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
background: #f3f4f6;
|
||
color: #111;
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tab.active {
|
||
background: #111;
|
||
color: #fff;
|
||
}
|
||
|
||
/* 卡片 */
|
||
.card {
|
||
margin-top: 12px;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(0,0,0,0.06);
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
|
||
padding: 14px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.card-full {
|
||
width: 100%;
|
||
}
|
||
|
||
.fullwide {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.card-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #111;
|
||
}
|
||
|
||
.card-desc {
|
||
font-size: 12px;
|
||
color: rgba(0,0,0,0.55);
|
||
}
|
||
|
||
/* 图表必须给高度(H5 否则可能 0 高) */
|
||
.chart-box {
|
||
width: 100%;
|
||
height: 360px; /* 建议用 px,H5 更稳 */
|
||
}
|
||
|
||
.fullwide .chart-box {
|
||
height: 420px; /* 大图更高 */
|
||
}
|
||
|
||
/* 关键:左右分栏(宽屏) */
|
||
.insights-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: stretch;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
/* 左边更宽,右边更窄 */
|
||
.insights-left {
|
||
flex: 2;
|
||
min-width: 0; /* 防止图表撑破 */
|
||
}
|
||
|
||
.insights-right {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.insights-right .card {
|
||
margin-top: 0;
|
||
}
|
||
|
||
/* 列表样式 */
|
||
.rank-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.rank-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||
}
|
||
|
||
.rank-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.rank-no {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 999px;
|
||
background: rgba(0,0,0,0.06);
|
||
text-align: center;
|
||
line-height: 28px;
|
||
font-size: 12px;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.rank-name {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
color: #111;
|
||
}
|
||
|
||
.rank-val {
|
||
font-size: 13px;
|
||
color: rgba(0,0,0,0.65);
|
||
}
|
||
|
||
.rank-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.chip {
|
||
padding: 3px 8px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.chip.pos {
|
||
background: rgba(34,197,94,0.12);
|
||
color: #16a34a;
|
||
}
|
||
|
||
.chip.neg {
|
||
background: rgba(239,68,68,0.12);
|
||
color: #dc2626;
|
||
}
|
||
|
||
/* 宽屏:KPI 4列 */
|
||
@media screen and (min-width: 960px) {
|
||
.kpi-card {
|
||
width: calc(25% - 9px);
|
||
}
|
||
}
|
||
|
||
/* 自适应:窄屏自动变一列(断点用 px) */
|
||
@media screen and (max-width: 960px) {
|
||
.insights-row {
|
||
flex-direction: column;
|
||
}
|
||
.chart-box {
|
||
height: 320px;
|
||
}
|
||
.fullwide .chart-box {
|
||
height: 360px;
|
||
}
|
||
}
|
||
</style>
|