Files
medical-mall/pages/mall/analytics/index.uvue
2026-01-23 16:33:11 +08:00

1133 lines
28 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page" @click="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'数据分析中心'"
:lastUpdateTime="lastUpdateTime"
:sidebarVisible="showSidebarMenu"
@menu-click="handleMenu"
@refresh="refreshAll"
@search="handleSearch"
@notification="handleNotification"
@fullscreen="handleFullscreen"
@mobile="handleMobile"
@dropdown="handleDropdown"
@settings="handleSettings"
/>
<view class="page-layout">
<!-- 侧边栏菜单组件 -->
<AnalyticsSidebarMenu
:visible="showSidebarMenu"
:currentPath="currentPath"
@visible-change="handleSidebarUpdate"
/>
<!-- 主内容区域 -->
<view class="main-content">
<view class="container">
<!-- 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>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
type SegmentItem = { name: string; value: number }
type TrafficItem = { name: string; value: number }
type TopProductItem = { id: string; rank: number; name: string; sales: number }
type TopMerchantItem = { id: string; rank: number; name: string; sales: number; growth: number }
export default {
components: {
AnalyticsComboChart,
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/index',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
],
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(newVal, oldVal) {
this.buildChartOptions()
},
userSegments(newVal, oldVal) {
this.buildChartOptions()
}
},
onLoad() {
this.refreshAll()
this.buildChartOptions()
},
onUnload() {
this.showMoreMenu = false
},
methods: {
async refreshAll() {
this.updateTime()
await this.loadRealTime()
await this.loadTrend()
await this.loadUserSegments()
await this.loadTrafficSources()
await this.loadTopProducts()
await this.loadTopMerchants()
this.updateTime()
uni.showToast({ title: '已刷新', icon: 'success' })
},
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadTrend()
},
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}`
},
calcDateRange() {
const now = new Date()
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 365
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
return { startDate, endDate }
},
async loadTrend() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
p.set('p_merchant_id', null)
const res: any = await supa.rpc('rpc_analytics_trend_data', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const x: Array<string> = []
const gmv: Array<number> = []
const orders: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const d = `${rows[i].date}` // yyyy-mm-dd
x.push(d.slice(5))
gmv.push(Number(rows[i].gmv) || 0)
orders.push(Number(rows[i].orders) || 0)
}
this.trend = { x, gmv, orders }
} catch (e) {
console.error('loadTrend failed', e)
}
},
// 实时指标:核心是"强制数值化 + 兜底",避免对象直接渲染
async loadRealTime() {
try {
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 p = new UTSJSONObject()
p.set('p_start', todayISO)
p.set('p_end', now.toISOString())
p.set('p_compare_start', y0.toISOString())
p.set('p_compare_end', ySame.toISOString())
p.set('p_merchant_id', null)
const res: any = await supa.rpc('rpc_analytics_realtime_kpis', p)
const row = Array.isArray(res.data) && res.data.length > 0 ? res.data[0] : (res.data || {})
const safe = (v: any): number => {
const n = Number(v)
return isFinite(n) ? n : 0
}
this.realTime = {
gmv: Math.round(safe(row.gmv)),
gmv_growth: safe(row.gmv_growth),
orders: Math.round(safe(row.orders)),
order_growth: safe(row.order_growth),
online_users: Math.round(safe(row.online_users)),
conversion_rate: safe(row.conversion_rate),
conversion_growth: safe(row.conversion_growth)
}
} catch (e) {
console.error(e)
}
},
async loadTopProducts() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
p.set('p_limit', 5)
p.set('p_merchant_id', null)
const res: any = await supa.rpc('rpc_analytics_top_products', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const list: Array<TopProductItem> = []
for (let i = 0; i < rows.length; i++) {
list.push({
id: `${rows[i].id}`,
rank: i + 1,
name: `${rows[i].name}`,
sales: Number(rows[i].sales) || 0
})
}
this.topProducts = list
} catch (e) {
console.error('loadTopProducts failed', e)
}
},
async loadTopMerchants() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
p.set('p_limit', 5)
const res: any = await supa.rpc('rpc_analytics_top_merchants', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const list: Array<TopMerchantItem> = []
for (let i = 0; i < rows.length; i++) {
list.push({
id: `${rows[i].id}`,
rank: i + 1,
name: `${rows[i].name}`,
sales: Number(rows[i].sales) || 0,
growth: Number(rows[i].growth) || 0
})
}
this.topMerchants = list
} catch (e) {
console.error('loadTopMerchants failed', e)
}
},
async loadUserSegments() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
const res: any = await supa.rpc('rpc_analytics_user_segments', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const list: Array<SegmentItem> = []
for (let i = 0; i < rows.length; i++) {
list.push({ name: `${rows[i].name}`, value: Number(rows[i].value) || 0 })
}
if (list.length > 0) this.userSegments = list
} catch (e) {
console.error('loadUserSegments failed', e)
}
},
async loadTrafficSources() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
const res: any = await supa.rpc('rpc_analytics_traffic_sources', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const list: Array<TrafficItem> = []
for (let i = 0; i < rows.length; i++) {
list.push({ name: `${rows[i].name}`, value: Number(rows[i].value) || 0 })
}
if (list.length > 0) this.trafficSources = list
} catch (e) {
console.error('loadTrafficSources failed', e)
}
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
handleSearch() {
uni.showToast({ title: '搜索功能', icon: 'none' })
},
handleNotification() {
uni.showToast({ title: '通知中心', icon: 'none' })
},
handleFullscreen() {
uni.showToast({ title: '全屏模式', icon: 'none' })
},
handleMobile() {
uni.showToast({ title: '移动端预览', icon: 'none' })
},
handleDropdown() {
uni.showActionSheet({
itemList: ['crmeb demo', '切换项目', '项目设置'],
success: () => {}
})
},
handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
},
handleMoreAction(action: string) {
this.showMoreMenu = false
switch (action) {
case 'refresh':
this.refreshAll()
break
case 'search':
this.handleSearch()
break
case 'notification':
this.handleNotification()
break
case 'fullscreen':
this.handleFullscreen()
break
case 'mobile':
this.handleMobile()
break
case 'settings':
this.handleSettings()
break
}
},
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
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;
}
/* 页面布局:宽屏时侧边栏+内容,窄屏时全屏内容 */
.page-layout {
display: flex;
flex-direction: row !important;
min-height: 100vh;
}
.main-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding-top: 64px; /* 为固定顶部导航栏留出空间 */
}
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 16px 16px 28px;
box-sizing: border-box;
flex: 1;
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
/* 顶部 */
/* ✅ 强制:顶部必须横排(避免被全局 view:flex-direction:column 影响) */
.topbar {
display: flex;
flex-direction: row !important;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.topbar-left {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.menu-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.menu-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.menu-icon .icon {
font-size: 18px;
color: #111;
line-height: 1;
}
/* 左侧标题组仍然是纵向 */
.title-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-width: 0; /* 允许内部 text 做省略 */
}
.title {
font-size: 18px;
font-weight: 700;
color: #111;
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: 12px;
color: rgba(0,0,0,0.55);
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ✅ 右侧按钮永不换成竖列(必要时只裁切,不换行) */
.topbar-right {
display: flex;
flex-direction: row !important;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
flex-shrink: 0;
position: relative;
white-space: nowrap;
}
.icon-btn {
padding: 8px 12px;
border-radius: 10px;
background: #f3f4f6;
color: #111;
font-size: 13px;
}
.icon-btn.primary {
background: #111;
color: #fff;
}
/* 图标按钮样式 */
.icon-btn-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
.icon-btn-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.icon-btn-icon .icon {
font-size: 16px;
line-height: 1;
}
/* 通知图标带红点 */
.icon-btn-icon.notification .badge {
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #ef4444;
border: 1px solid #fff;
}
/* 下拉菜单 */
.dropdown {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 4px;
padding: 6px 10px;
border-radius: 8px;
background: #f3f4f6;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
/* 更多按钮(默认隐藏,窄屏时显示) */
.more-btn {
display: none;
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
.more-btn.active {
background: #e5e7eb;
}
.more-btn .icon {
font-size: 18px;
line-height: 1;
color: #111;
}
/* 更多菜单下拉 */
.more-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: #fff;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
min-width: 140px;
z-index: 1000;
overflow: hidden;
display: flex;
flex-direction: column !important;
}
.more-menu-item {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
transition: background 0.2s;
}
.more-menu-item:active {
background: #f3f4f6;
}
.more-menu-item .icon {
font-size: 16px;
line-height: 1;
}
.more-menu-item .text {
font-size: 13px;
color: #111;
}
.dropdown:active {
background: #e5e7eb;
}
.dropdown-text {
font-size: 13px;
color: #111;
}
.dropdown-arrow {
font-size: 10px;
color: #666;
line-height: 1;
}
/* KPI默认 2列宽屏 4列 */
/* ✅ 核心修复:用 flex + calc(50%) 替代 width避免 rpx + CSS var 失效 */
.kpi-grid {
margin-top: 12px;
display: flex;
flex-direction: row !important;
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;
flex: 1 1 calc(50% - 6px);
min-width: 260px; /* 窄屏自动掉到一列 */
}
.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;
flex-direction: row !important;
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; /* 建议用 pxH5 更稳 */
}
.fullwide .chart-box {
height: 420px; /* 大图更高 */
}
/* 关键:左右分栏(宽屏) */
/* ✅ 修复:确保 flex 布局在 H5 正常工作 */
.insights-row {
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
align-items: stretch;
margin-top: 12px;
}
/* 左边更宽,右边更窄 */
.insights-left {
flex: 1 1 calc(66.666% - 8px);
min-width: 360px; /* 窄屏自动掉到一列 */
}
.insights-right {
flex: 1 1 calc(33.333% - 8px);
min-width: 360px; /* 窄屏自动掉到一列 */
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;
flex-direction: row !important;
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;
flex-direction: row !important;
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 {
flex: 1 1 calc(25% - 9px);
min-width: 200px;
}
/* 宽屏时显示所有按钮,隐藏"更多"按钮 */
.topbar-right .btn-hidden {
display: flex !important;
}
.more-btn {
display: none !important;
}
}
/* 自适应:窄屏自动变一列(断点用 px */
@media screen and (max-width: 960px) {
.insights-row {
flex-direction: column;
}
.insights-left,
.insights-right {
flex: 1 1 100%;
min-width: 100%;
}
.chart-box {
height: 320px;
}
.fullwide .chart-box {
height: 360px;
}
/* 顶部栏按钮在小屏幕上:隐藏部分按钮,显示"更多"按钮 */
.topbar-right {
gap: 6px;
}
/* 隐藏标记为 btn-hidden 的按钮 */
.topbar-right .btn-hidden {
display: none !important;
}
/* 显示"更多"按钮 */
.more-btn {
display: flex !important;
}
/* 标题在窄屏时允许省略号 */
.title,
.subtitle {
max-width: 200px;
}
.icon-btn-icon {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.icon-btn-icon .icon {
font-size: 14px;
}
.dropdown {
padding: 4px 8px;
flex-shrink: 0;
}
.dropdown-text {
font-size: 12px;
}
}
</style>