Files
medical-mall/pages/mall/analytics/user-analysis.uvue
2026-02-06 16:18:04 +08:00

953 lines
26 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.self="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'用户分析'"
:lastUpdateTime="lastUpdateTime"
:sidebarVisible="showSidebarMenu"
@menu-click="handleMenu"
@refresh="refreshData"
@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">
<!-- 全局筛选区 -->
<view class="filters">
<view class="filter-block">
<text class="filter-label">时间范围</text>
<view class="tabs">
<view
v-for="p in timePeriods"
:key="p.value"
class="tab"
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
<view
class="tab"
:class="{ active: customRangeEnabled }"
@click="toggleCustomRange"
>
自定义
</view>
</view>
</view>
<AnalyticsDateRangePicker
v-if="customRangeEnabled"
:initialStartDate="selectedStartDate"
:initialEndDate="selectedEndDate"
@apply="onDateRangeApply"
@clear="onDateRangeClear"
/>
<view class="filter-hint">
<text class="filter-hint-text">渠道/终端/会员/新老:待接入数据后开放</text>
</view>
</view>
<!-- 核心 KPI电商化 -->
<view class="kpi-grid kpi-grid-6">
<view class="kpi-card">
<text class="kpi-label">新增用户</text>
<text class="kpi-value">{{ formatInt(userData.new_users) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(userData.new_user_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">活跃用户DAU</text>
<text class="kpi-value">{{ formatInt(userData.active_users) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(userData.active_growth) }}</text>
</view>
<view class="kpi-card" v-if="hasOrderingData">
<text class="kpi-label">下单用户数</text>
<text class="kpi-value">{{ formatInt(userData.ordering_users) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(userData.ordering_growth) }}</text>
</view>
<view class="kpi-card" v-if="hasPaidData">
<text class="kpi-label">支付用户数</text>
<text class="kpi-value">{{ formatInt(userData.paid_users) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(userData.paid_growth) }}</text>
</view>
<view class="kpi-card" v-if="hasNewConversionData">
<text class="kpi-label">新客转化率</text>
<text class="kpi-value">{{ formatPct(userData.new_user_conversion_rate) }}</text>
<text class="kpi-meta">新客 → 下单/支付</text>
</view>
<view class="kpi-card">
<text class="kpi-label">复购率</text>
<text class="kpi-value">{{ formatPct(userData.repurchase_rate) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(userData.repurchase_growth) }}</text>
</view>
</view>
<!-- 增长与活跃趋势 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">增长与活跃趋势</text>
<text class="card-desc">{{ selectedPeriodText }} · 新增 vs 活跃DAU</text>
</view>
<view v-if="loading || !growthChartOption || !growthChartOption.series || growthChartOption.series.length === 0" class="chart-loading">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<EChartsView v-else class="chart-box" :option="growthChartOption" />
</view>
<view class="two-col">
<view class="card two-col-card">
<view class="card-head">
<text class="card-title">新客转化趋势</text>
<text class="card-desc">新客 → 下单/支付(待接入)</text>
</view>
<view class="chart-loading chart-loading-sm">
<text>暂无数据 / 待接入</text>
</view>
</view>
<view class="card two-col-card">
<view class="card-head">
<text class="card-title">回访 / 复购趋势</text>
<text class="card-desc">复购人数 / 复购率(待接入)</text>
</view>
<view class="chart-loading chart-loading-sm">
<text>暂无数据 / 待接入</text>
</view>
</view>
</view>
<!-- 转化漏斗 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">转化漏斗</text>
<text class="card-desc">拉新 → 激活 → 转化(待接入埋点/事件)</text>
</view>
<view class="funnel">
<view class="funnel-steps">
<view class="funnel-step" v-for="(s, idx) in funnelSteps" :key="idx">
<view class="funnel-step-name">
<text class="funnel-step-index">{{ idx + 1 }}</text>
<text class="funnel-step-text">{{ s.step }}</text>
</view>
<view class="funnel-step-metrics">
<text class="funnel-step-value">{{ formatInt(s.value) }}</text>
<text class="funnel-step-rate" v-if="idx > 0">转化:{{ formatPct(calcFunnelRate(idx)) }}</text>
<text class="funnel-step-rate" v-else>—</text>
</view>
</view>
</view>
<view class="funnel-empty" v-if="!hasFunnelData">
<text class="funnel-empty-text">暂无漏斗数据 / 待接入UV、PDP、加购、下单、支付</text>
</view>
</view>
</view>
<!-- 留存 / 回访 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">留存与回访</text>
<text class="card-desc">{{ selectedPeriodText }} · 1/3/7/14/30日留存Cohort 后续补)</text>
</view>
<view class="two-col">
<view class="two-col-item">
<view class="sub-head">
<text class="sub-title">留存曲线</text>
<text class="sub-desc">留存率趋势(待接入)</text>
</view>
<EChartsView class="chart-box chart-box-sm" :option="retentionChartOption" />
</view>
<view class="two-col-item">
<view class="sub-head">
<text class="sub-title">流失用户占比</text>
<text class="sub-desc">7/14天未活跃待接入</text>
</view>
<view class="metric-empty">
<text class="metric-empty-text">暂无数据 / 待接入</text>
</view>
</view>
</view>
</view>
<!-- 用户分群(运营可用) -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">用户分群(运营可用)</text>
<text class="card-desc">RFM / LTV / 新客分层(后续补) · 当前为基础结构占比</text>
</view>
<view class="two-col">
<view class="two-col-item">
<EChartsView class="chart-box" :option="segmentChartOption" />
</view>
<view class="two-col-item">
<view class="sub-head">
<text class="sub-title">用户画像(基础)</text>
<text class="sub-desc">性别/年龄/地域(待接入)</text>
</view>
<EChartsView class="chart-box chart-box-sm" :option="profileChartOption" />
</view>
</view>
</view>
<!-- 渠道来源Acquisition -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">渠道来源</text>
<text class="card-desc">{{ selectedPeriodText }} · 渠道占比(后续可扩展渠道质量表)</text>
</view>
<EChartsView class="chart-box" :option="trafficChartOption" />
</view>
<!-- 留白 -->
<view style="height: 24px;"></view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
import { computed, reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { TimePeriod } from '@/types/analytics/common.uts'
import type { UserData, FunnelStep } from '@/types/analytics/user.uts'
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const customRangeEnabled = ref(false)
const selectedStartDate = ref('')
const selectedEndDate = ref('')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/user-analysis')
const timePeriods = ref<Array<TimePeriod>>([
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
])
const userData = reactive<UserData>({
total_users: 0,
user_growth: 0,
new_users: 0,
new_user_growth: 0,
active_users: 0,
active_growth: 0,
ordering_users: 0,
ordering_growth: 0,
paid_users: 0,
paid_growth: 0,
new_user_conversion_rate: 0,
repurchase_rate: 0,
repurchase_growth: 0
})
const growthChartOption = ref<any>({})
const retentionChartOption = ref<any>({})
const activityChartOption = ref<any>({})
const comparisonChartOption = ref<any>({})
const profileChartOption = ref<any>({})
const segmentChartOption = ref<any>({})
const trafficChartOption = ref<any>({})
const funnelSteps = reactive<Array<FunnelStep>>([
{ step: '访问用户UV', value: 0 },
{ step: '商品详情页PDP UV', value: 0 },
{ step: '加购用户', value: 0 },
{ step: '下单用户', value: 0 },
{ step: '支付用户', value: 0 }
])
const loading = ref(false)
const selectedPeriodText = computed((): string => {
const p = timePeriods.value.find((t) => t.value === selectedPeriod.value)
return p ? p.label : '7天'
})
const hasOrderingData = computed((): boolean => {
return userData.ordering_users > 0 || userData.ordering_growth != 0
})
const hasPaidData = computed((): boolean => {
return userData.paid_users > 0 || userData.paid_growth != 0
})
const hasNewConversionData = computed((): boolean => {
return userData.new_user_conversion_rate != 0
})
const hasFunnelData = computed((): boolean => {
for (let i = 0; i < funnelSteps.length; i++) {
if ((funnelSteps[i].value ?? 0) > 0) return true
}
return false
})
onLoad(() => {
updateTime()
loadUserData()
})
function calcDateRange() {
if (selectedStartDate.value && selectedEndDate.value) {
return { startDate: new Date(selectedStartDate.value), endDate: new Date(selectedEndDate.value) }
}
const now = new Date()
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = selectedPeriod.value === '7d' ? 7 : selectedPeriod.value === '30d' ? 30 : selectedPeriod.value === '90d' ? 90 : 365
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
return { startDate, endDate }
}
function updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
lastUpdateTime.value = `${hh}:${mm}`
}
function safeNumber(v: any): number {
const n = Number(v)
return isFinite(n) ? n : 0
}
async function loadUserData() {
loading.value = true
try {
updateTime()
await ensureSupabaseReady()
const { startDate, endDate } = calcDateRange()
const startStr = startDate.toISOString().slice(0, 10)
const endStr = endDate.toISOString().slice(0, 10)
const p = new UTSJSONObject()
p.set('p_start_date', startStr)
p.set('p_end_date', endStr)
const res: any = await supa.rpc('rpc_analytics_user_kpis', p)
const row = Array.isArray(res.data) && res.data.length > 0 ? res.data[0] : (res.data || {})
userData.total_users = Math.round(safeNumber(row.total_users))
userData.user_growth = safeNumber(row.user_growth)
userData.new_users = Math.round(safeNumber(row.new_users))
userData.new_user_growth = safeNumber(row.new_user_growth)
userData.active_users = Math.round(safeNumber(row.active_users))
userData.active_growth = safeNumber(row.active_growth)
userData.ordering_users = Math.round(safeNumber(row.ordering_users))
userData.ordering_growth = safeNumber(row.ordering_growth)
userData.paid_users = Math.round(safeNumber(row.paid_users))
userData.paid_growth = safeNumber(row.paid_growth)
userData.new_user_conversion_rate = safeNumber(row.new_user_conversion_rate)
userData.repurchase_rate = safeNumber(row.repurchase_rate)
userData.repurchase_growth = safeNumber(row.repurchase_growth)
const tRes: any = await supa.rpc('rpc_analytics_user_growth_trend', p)
const rows: Array<any> = Array.isArray(tRes.data) ? (tRes.data as Array<any>) : []
const x: Array<string> = []
const newArr: Array<number> = []
const activeArr: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const d = `${rows[i].date}`
x.push(d.slice(5))
newArr.push(Number(rows[i].new_users) || 0)
activeArr.push(Number(rows[i].active_users) || 0)
}
growthChartOption.value = {
grid: { left: 40, right: 18, top: 20, bottom: 40 },
tooltip: { trigger: 'axis' },
legend: { data: ['新增用户', '活跃用户DAU'], bottom: 0 },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
series: [
{ name: '新增用户', type: 'line', data: newArr, smooth: true, symbolSize: 6, areaStyle: { opacity: 0.08 } },
{ name: '活跃用户DAU', type: 'line', data: activeArr, smooth: true, symbolSize: 6 }
]
}
const sRes: any = await supa.rpc('rpc_analytics_user_segments', p)
const sRows: Array<any> = Array.isArray(sRes.data) ? (sRes.data as Array<any>) : []
const segData: Array<any> = []
for (let i = 0; i < sRows.length; i++) {
const name = `${sRows[i].name}`
const value = Number(sRows[i].value) || 0
segData.push({ name, value })
}
segmentChartOption.value = {
tooltip: { trigger: 'item' },
legend: { top: 10, left: 'center', padding: [12, 0, 24, 0] },
series: [
{
name: '用户分群',
type: 'pie',
center: ['48%', '60%'],
radius: ['42%', '66%'],
avoidLabelOverlap: true,
label: { show: true, formatter: '{b}\n{d}%' },
labelLine: { length: 14, length2: 10 },
data: segData
}
]
}
const t2Res: any = await supa.rpc('rpc_analytics_traffic_sources', p)
const tRows: Array<any> = Array.isArray(t2Res.data) ? (t2Res.data as Array<any>) : []
const srcNames: Array<string> = []
const srcVals: Array<number> = []
for (let i = 0; i < tRows.length; i++) {
const name = `${tRows[i].name}`
const value = Number(tRows[i].value) || 0
srcNames.push(name)
srcVals.push(value)
}
trafficChartOption.value = {
grid: { left: 60, right: 18, top: 20, bottom: 40 },
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: srcNames, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
series: [{ type: 'bar', data: srcVals, barWidth: 14, itemStyle: { borderRadius: 6 } }]
}
funnelSteps.splice(0, funnelSteps.length,
{ step: '访问用户UV', value: 0 },
{ step: '商品详情页PDP UV', value: 0 },
{ step: '加购用户', value: 0 },
{ step: '下单用户', value: 0 },
{ step: '支付用户', value: 0 }
)
retentionChartOption.value = { title: { text: '留存率(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
activityChartOption.value = { title: { text: '活跃度(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
comparisonChartOption.value = { title: { text: '新老用户对比(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
profileChartOption.value = { title: { text: '用户画像(待接入:需要性别/年龄/地域字段)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
} catch (e) {
console.error('loadUserData failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '数据加载失败' }), icon: 'none', duration: 2000 })
} finally {
loading.value = false
updateTime()
}
}
function selectPeriod(p: string) {
selectedPeriod.value = p
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loadUserData()
}
function toggleCustomRange() {
customRangeEnabled.value = !customRangeEnabled.value
}
function onDateRangeApply(range: { start: string; end: string }) {
selectedStartDate.value = range.start
selectedEndDate.value = range.end
customRangeEnabled.value = true
loadUserData()
}
function onDateRangeClear() {
selectedStartDate.value = ''
selectedEndDate.value = ''
customRangeEnabled.value = false
loadUserData()
}
function refreshData() {
loadUserData()
uni.showToast({ title: '已刷新', icon: 'success' })
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
}
function formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
}
function formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
}
function calcFunnelRate(idx: number): number {
if (idx <= 0 || idx >= funnelSteps.length) return 0
const prev = Number(funnelSteps[idx - 1].value) || 0
const cur = Number(funnelSteps[idx].value) || 0
if (prev <= 0) return 0
return (cur / prev) * 100
}
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
}
function handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
}
function handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
}
function handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
}
function handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
}
function handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
</script>
<style>
.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; /* 为固定顶部导航栏留出空间 */
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 16px 16px 28px;
box-sizing: border-box;
}
/* 全局筛选区 */
.filters {
margin-top: 12px;
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0,0,0,0.06);
padding: 16px;
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
}
.filter-block {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.filter-label {
font-size: 13px;
font-weight: 500;
color: rgba(0,0,0,0.8);
white-space: nowrap;
}
.filter-hint {
margin-top: 8px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 8px;
border-left: 3px solid #e9ecef;
}
.filter-hint-text {
font-size: 12px;
color: #6c757d;
}
/* 时间维度 tabs */
.tabs {
display: flex;
flex-direction: row !important;
gap: 6px;
background: #f8f9fa;
border-radius: 10px;
padding: 4px;
flex: 1;
overflow-x: auto;
}
.tab {
padding: 6px 12px;
border-radius: 8px;
background: transparent;
color: #495057;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
cursor: pointer;
transition: all 0.2s;
}
.tab.active {
background: #fff;
color: #111;
font-weight: 500;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* KPI 网格 */
.kpi-grid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.kpi-grid-6 {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.kpi-card {
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
padding: 16px;
box-sizing: border-box;
transition: transform 0.2s, box-shadow 0.2s;
min-width: 0;
}
.kpi-card:active {
transform: translateY(1px);
}
@media (min-width: 1200px) {
.kpi-grid-6 {
grid-template-columns: repeat(6, 1fr);
}
}
@media (max-width: 1199px) and (min-width: 768px) {
.kpi-grid-6 {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 767px) {
.kpi-grid-6 {
grid-template-columns: repeat(2, 1fr);
}
.kpi-card {
padding: 14px 12px;
}
.kpi-value {
font-size: 20px !important;
}
}
.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);
}
/* 卡片 */
.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%;
}
.card-head {
display: flex;
flex-direction: row !important;
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);
}
.chart-box {
width: 100%;
height: 360px;
}
.chart-box-sm {
height: 240px;
}
.chart-loading {
width: 100%;
height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0,0,0,0.45);
font-size: 14px;
}
.two-col {
display: flex;
flex-direction: row !important;
gap: 12px;
}
.two-col-item {
flex: 1;
min-width: 0;
}
.two-col-card {
flex: 1;
min-width: 0;
margin-top: 0;
}
.chart-loading-sm {
height: 260px;
}
.quad-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.quad-item {
border: 1px solid rgba(0,0,0,0.06);
border-radius: 14px;
padding: 12px;
box-sizing: border-box;
}
.sub-head {
display: flex;
flex-direction: row !important;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
}
.sub-title {
font-size: 13px;
font-weight: 600;
color: #111;
}
.sub-desc {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
@media screen and (max-width: 960px) {
.two-col {
flex-direction: column !important;
}
.quad-grid {
grid-template-columns: 1fr;
}
}
/* 漏斗 */
.funnel {
width: 100%;
}
.funnel-steps {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.funnel-step {
border: 1px solid rgba(0,0,0,0.06);
border-radius: 12px;
padding: 12px;
background: #fff;
position: relative;
}
.funnel-step-name {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 8px;
}
.funnel-step-index {
width: 20px;
height: 20px;
line-height: 20px;
border-radius: 999px;
background: #111;
color: #fff;
text-align: center;
font-size: 12px;
}
.funnel-step-text {
font-size: 12px;
color: rgba(0,0,0,0.75);
}
.funnel-step-metrics {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.funnel-step-value {
font-size: 18px;
font-weight: 800;
color: #111;
}
.funnel-step-rate {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.funnel-empty {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
background: #f8f9fa;
border: 1px dashed rgba(0,0,0,0.15);
}
.funnel-empty-text {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.metric-empty {
height: 240px;
border-radius: 12px;
border: 1px dashed rgba(0,0,0,0.15);
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
}
.metric-empty-text {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
@media screen and (max-width: 960px) {
.funnel-steps {
grid-template-columns: 1fr;
}
}
</style>