@@ -1,19 +1,23 @@
<!-- 访客统计 · 数据总览 -- >
<!-- 访客统计 · 数据总览 ( 客迹视觉 V1 : 周期四卡 + 趋势双线 + 渠道条形 + 最近访问 ) -- >
< template >
< div class = "tracker-dashboard-page" >
<!-- 筛选栏 : 站点切换 + 刷新 -- >
< div class = "art-card mb-4 flex-c flex-wrap gap-3 px-5 py-4 " >
< ArtSvgIcon icon = "ri:bar-chart-box-l ine " class = "text-lg text-theme" / >
< h2 class = "m-0 text-base font-semibold " > 数据总 览 < / h2 >
< span class = "text-xs text-g-500" > 今日指标为上海自然日口径 , 已排除爬虫 < / span >
< div class = "ml-auto flex-c gap-2" >
< span class = "text-sm text-g-600" > 站点 < / span >
< div class = "tracker-dashboard-page p-5 " >
<!-- 页头 : 标题 / 副标题 + 在线胶囊 + 站点 / 周期筛选 -- >
< div class = "db-header " >
< div class = "db-header-ma in" >
< h2 class = "db-title " > 数据概 览 < / h2 >
< p class = "db-subtitle" > 了解每一次访问 , 连接每一个触点 < / p >
< / div >
< div class = "db-header-tools" >
< span class = "db-online" : class = "{ 'is-online': onlineCount > 0 }" >
< i class = "db-online-dot" / >
在线 { { onlineCount } }
< / span >
<!-- 宽度用内联 style : EP 未分层 width 规则会压过 Tailwind utilities 层的 w - * 类 -- >
< ElSelect
v-model = "selectedSiteId"
style = "width: 256 px"
style = "width: 220 px"
:loading = "sitesLoading"
@change ="loadOverview "
@change ="onSiteChange "
>
< ElOption label = "全部站点" value = "ALL_SITES" / >
< ElOption
@@ -23,120 +27,153 @@
:value = "site.site_id"
/ >
< / ElSelect >
< ElTooltip content = "刷新数据" placement = "top ">
< ElButton circle :icon = "RefreshIcon" :loading = "overviewLoading" @click ="refreshAll" / >
< / ElTooltip >
< ElSelect v-model = "range" style="width: 132px" @change="loadOverview " >
< ElOption label = "今天" value = "today" / >
< ElOption label = "近 7 天" value = "7d" / >
< ElOption label = "近 30 天" value = "30d" / >
< / ElSelect >
< / div >
< / div >
<!-- 今日五指标 -- >
< div
v-loading = "overviewLoading"
class = "grid grid-cols-2 gap-4 mb-4 md:grid-cols-3 xl:grid-cols-5"
>
< div
v-for = "card in statCards"
:key = "card.label"
class = "art-card relative h-30 flex flex-col justify-center px-5"
>
< span class = "text-sm text-g-600" > { { card . label } } < / span >
< ArtCountTo class = "mt-1.5 text-[26px] font-medium" :target = "card.value" :duration = "1300" / >
< div class = "absolute top-0 bottom-0 right-5 m-auto size-11 rounded-lg flex-cc bg-theme/10" >
< ArtSvgIcon :icon = "card.icon" class = "text-lg text-theme" / >
< / div >
< / div >
< / div >
<!-- 近 30 天趋势 -- >
< div class = "art-card mb-4 px-5 py-4" >
< div class = "flex-c flex-wrap gap-2 mb-2" >
< h3 class = "m-0 text-sm font-semibold" > 近 30 天趋势 < / h3 >
< span class = "text-xs text-g-500" > 今日为实时值 , 历史为每日聚合 < / span >
< / div >
< ArtLineChart
:data = "trendSeries"
:x-axis-data = "trendDates"
:colors = "lineColors"
:loading = "overviewLoading"
height = "22rem"
show -legend
legend -position = " top "
/ >
< / div >
<!-- 来源构成 + 热门页面 -- >
< div class = "grid grid-cols-1 gap-4 xl:grid-cols-2" >
< div class = "art-card px-5 py-4" >
< div class = "flex-c flex-wrap gap-2 mb-2" >
< h3 class = "m-0 text-sm font-semibold" > 渠道构成 ( 今日 TOP8 ) < / h3 >
< span class = "text-xs text-g-500" > 按归因规则聚合会话数 , 未重算的历史会话不计入 < / span >
< / div >
< ArtRingChart
:data = "channelData"
:colors = "channelColors"
:loading = "overviewLoading"
height = "15rem"
show -legend
legend -position = " right "
/ >
<!-- 平台明细 : 原始 referrer 域名口径 ( 与渠道归因互补 , 域名级下钻 ) -- >
< div class = "mt-4" v-loading = "overviewLoading" >
< div class = "flex-c flex-wrap gap-2 mb-2" >
< h4 class = "m-0 text-xs font-semibold text-g-600" > 平台明细 ( 今日 TOP8 ) < / h4 >
< span class = "text-xs text-g-500" > 无 referrer 归为 "直接访问" < / span >
<!-- 4 周期统计卡 -- >
< div v-loading = "overviewLoading" class="db-stats" >
< div v-for = "card in statCards" :key="card.label" class="vd-card db-stat-card" >
< div class = "db-stat-top" >
< div class = "db-stat-icon" >
< ElIcon :size = "20" >
< component :is = "card.icon" / >
< / ElIcon >
< / div >
< ElTable :data = "platformSources" size = "small" max -height = " 240 " >
< ElTableColumn prop = "source" label = "来源域名" min -width = " 160 " show -overflow -tooltip >
< template # default = "{ row }" >
< span class = "text-xs" > { { row . source } } < / span >
< / template >
< / ElTableColumn >
< ElTableColumn prop = "sessions" label = "会话数" width = "90" align = "right" / >
< / ElTable >
< span class = "db-stat-label" > { { card . label } } < / span >
< / div >
< span class = "db-stat-value" > { { card . value } } < / span >
< div class = "db-stat-trend" >
< span v-if = "card.delta === null" class="db-delta is-none" > — < / span >
< span
v-else
class = "db-delta"
: class = "card.delta >= 0 ? 'is-up' : 'is-down'"
>
{ { card . delta >= 0 ? '↑' : '↓' } } { { Math . abs ( card . delta ) } } %
< / span >
< span class = "db-delta-caption" > 较上一周期 < / span >
< / div >
< / div >
< / div >
<!-- 双栏图表行 : 访问趋势 ( 2 / 3 ) + 来源分布 ( 1 / 3 ) -- >
< div class = "db-charts" >
< div class = "vd-card db-chart-card" >
< div class = "db-card-head" >
< div class = "db-card-title" >
< ElIcon :size = "16" > < TrendChartsIcon / > < / ElIcon >
< span > 访问趋势 < / span >
< / div >
< span class = "db-card-hint" > { { RANGE _HINTS [ range ] } } , 今日为实时值 < / span >
< / div >
< div ref = "trendRef" class = "db-chart-body" / >
< / div >
< div class = "art -card px-5 py-4" v-loading = "overviewLoading ">
< div class = "flex-c flex-wrap gap-2 mb-2 " >
< h3 class = "m-0 text-sm font-semibold" > 热门页面 ( 今日 TOP8 ) < / h3 >
< span class = "text-xs text-g-500" > 按 PV 统计 < / span >
< div class = "vd -card db-chart-card " >
< div class = "db-card-head " >
< div class = "db-card-title" >
< ElIcon :size = "16" > < HistogramIcon / > < / ElIcon >
< span > 来源分布 < / span >
< / div >
< span class = "db-card-hint" > 按会话入口归因 < / span >
< / div >
< ElTable :data = "topPages " height = "20rem" >
< ElTableColumn type = "index" label = "#" width = "50" / >
< ElTableColumn prop = "path" label = "页面路径" show -overflow -tooltip min -width = " 200 " >
< template # default = "{ row }" >
< span class = "font-mono text-xs" > { { row . path } } < / span >
< / template >
< / ElTableColumn >
< ElTableColumn prop = "pv" label = "PV" width = "80" align = "right" sortable / >
< / ElTable >
< div ref = "channelRef " class = "db-chart-body" / >
< / div >
< / div >
<!-- 最近访问表 -- >
< div class = "vd-card db-table-card" >
< div class = "db-card-head" >
< div class = "db-card-title" >
< ElIcon :size = "16" > < ClockIcon / > < / ElIcon >
< span > 最近访问 < / span >
< / div >
< ElButton link type = "primary" @click ="goVisitorList" > 查看更多 → < / ElButton >
< / div >
< ElTable
:data = "recentVisitors"
v-loading = "visitorsLoading"
empty -text = " 暂无访客数据 "
row -class -name = " db -clickable -row "
@ row -click = " openVisitor "
>
< ElTableColumn label = "访客" min -width = " 210 " >
< template # default = "{ row }" >
< div class = "db-visitor-cell" >
< code class = "db-visitor-code" > { { row . visitor _id . slice ( 0 , 8 ) } } < / code >
< span
v-if = "row.channel"
class = "db-badge"
:style = "badgeStyle(row)"
> { { channelBadge ( row ) } } < / span >
< / div >
< / template >
< / ElTableColumn >
< ElTableColumn label = "本次来源" min -width = " 150 " show -overflow -tooltip >
< template # default = "{ row }" >
< span class = "db-source-text" > { { row . last _source || '—' } } < / span >
< / template >
< / ElTableColumn >
< ElTableColumn label = "浏览页面" min -width = " 200 " show -overflow -tooltip >
< template # default = "{ row }" >
< span class = "db-path-text" > { { row . last _path || '—' } } < / span >
< / template >
< / ElTableColumn >
< ElTableColumn label = "有效停留" width = "130" align = "right" >
< template # default = "{ row }" > { { formatStay ( row . total _stay _ms ) } } < / template >
< / ElTableColumn >
< ElTableColumn label = "最近访问" width = "130" >
< template # default = "{ row }" > { { formatSmartTime ( row . last _seen _at ) } } < / template >
< / ElTableColumn >
< / ElTable >
< / div >
< / div >
< / template >
< script setup lang = "ts" >
import { Refresh as RefreshIcon } from '@element-plus/icons- vue'
import { useChartOps } from '@/hooks/core/useChart'
import { markRaw } from 'vue'
import {
Clock as ClockIcon ,
Histogram as HistogramIcon ,
Timer as TimerIcon ,
TrendCharts as TrendChartsIcon ,
User as UserIcon ,
UserFilled as UserFilledIcon ,
VideoPlay as VideoPlayIcon
} from '@element-plus/icons-vue'
import { graphic } from '@/plugins/echarts'
import { useChart } from '@/hooks/core/useChart'
import { getSites , type SiteRow } from '@/api/tracker/sites'
import { getOverview , type OverviewResp } from '@/api/tracker/dashboard'
import { CHANNEL _LABELS } from '@/utils/tracker'
import {
getOverview ,
type OverviewRange ,
type OverviewResp
} from '@/api/tracker/dashboard'
import { getVisitors , type VisitorRow } from '@/api/tracker/visitors'
import { CHANNEL _LABELS , channelBadge , formatSmartTime , formatStay } from '@/utils/tracker'
defineOptions ( { name : 'TrackerDashboard' } )
/** 图表色板:模板主题色为主,剔除过浅色与重复色(useChartOps 默认第 3 位 #EDF2FF 在浅色底不可见) */
const themeColor = useChartOps ( ) . themeColor
const lineColors = [ themeColor , '#4ABEFF' , '#14DEBA' , '#FFAF20' ]
// 客迹视觉 V1 图表用色(双线:主蓝 + 青;与详情页色板同源)
const TREND _COLORS = { uv : '#2563EB' , sessions : '#06B6D4' }
const RANGE _HINTS : Record < OverviewRange , string > = {
today : '今天' ,
'7d' : '近 7 天' ,
'30d' : '近 30 天'
}
// 站点筛选(哨兵值 ALL_SITES = 全部站点;空串会被 ElSelect 当空值显示 placeholder)
// ---------- 站点 / 周期筛选 ----------
// 哨兵值 ALL_SITES = 全部站点(空串会被 ElSelect 当空值显示 placeholder)
const ALL _SITES = 'ALL_SITES'
const sites = ref < SiteRow [ ] > ( [ ] )
const sitesLoading = ref ( false )
const selectedSiteId = ref ( ALL _SITES )
// 总览数据
const overview = ref < OverviewResp | null > ( null )
const overviewLoading = ref ( false )
const range = ref < OverviewRange > ( '7d' )
const loadSites = async ( ) => {
sitesLoading . value = true
@@ -149,74 +186,528 @@
}
}
// 请求令牌:快速切换站点时丢弃过期响应,避免旧数据覆盖新数据/旧 finally 提前清 loading
// ---------- 总览(周期卡 + 趋势 + 渠道) ----------
const overview = ref < OverviewResp | null > ( null )
const overviewLoading = ref ( false )
// 请求令牌:快速切周期/站点时丢弃过期响应,避免旧数据覆盖新数据/旧 finally 提前清 loading
let reqSeq = 0
const loadOverview = async ( ) => {
const id = ++ reqSeq
overviewLoading . value = true
try {
const resp = await getOverview (
selectedSiteId . value === ALL _SITES ? undefined : selectedSiteId . value
)
const resp = await getOverview ( {
site : selectedSiteId . value === ALL _SITES ? undefined : selectedSiteId . value ,
range : range . value
} )
if ( id === reqSeq ) overview . value = resp
} catch {
// 失败已由请求层统一 ElMessage 提示;overview 置空让图表/表格 走空态
// 失败已由请求层统一 ElMessage 提示;overview 置空走空态
if ( id === reqSeq ) overview . value = null
} finally {
if ( id === reqSeq ) overviewLoading . value = false
}
}
const refreshAll = ( ) => {
loadSites ( )
// 站点切换:总览与最近访问都按站点过滤,需同时重拉
const onSiteChange = ( ) => {
loadOverview ( )
loadRecentVisitors ( )
}
onMounted ( ( ) => {
loadSites ( )
loadOverview ( )
} )
const onlineCount = computed ( ( ) => overview . value ? . header . online ? ? 0 )
// 今日五指标卡
const statCards = computed ( ( ) => [
{ label : '今日 PV' , value : overview . value ? . today . pv ? ? 0 , icon : 'ri:eye-line' } ,
{ label : '今日 UV' , value : overview . value ? . today . uv ? ? 0 , icon : 'ri:user-line' } ,
{ label : '今日会话' , value : overview . value ? . today . sessions ? ? 0 , icon : 'ri:door-open-line' } ,
/** 千分位(访客数/访问次数/回访访客) */
const fmtInt = ( n : number ) => n . toLocaleString ( 'en-US' )
// 四张周期卡:值 + 环比(delta 为 null 显示灰 —,负值红 ↓)
const statCards = computed < { label : string ; icon : Component ; value : string ; delta : number | null } [ ] > ( ( ) => [
{
label : '今日新 访客' ,
value : overview . value ? . today . new _visitors ? ? 0 ,
icon : 'ri:user-add-line'
label : '访客数 ' ,
icon : markRaw ( UserIcon ) ,
value : fmtInt ( overview . value ? . cards . visitors . value ? ? 0 ) ,
delta : overview . value ? . cards . visitors . delta _pct ? ? null
} ,
{ label : '在线访客' , value : overview . value ? . today . active _now ? ? 0 , icon : 'ri:pulse-line' }
{
label : '访问次数' ,
icon : markRaw ( VideoPlayIcon ) ,
value : fmtInt ( overview . value ? . cards . visits . value ? ? 0 ) ,
delta : overview . value ? . cards . visits . delta _pct ? ? null
} ,
{
label : '平均有效停留' ,
icon : markRaw ( TimerIcon ) ,
value : formatStay ( overview . value ? . cards . avg _stay _ms . value ? ? 0 ) ,
delta : overview . value ? . cards . avg _stay _ms . delta _pct ? ? null
} ,
{
label : '回访访客' ,
icon : markRaw ( UserFilledIcon ) ,
value : fmtInt ( overview . value ? . cards . returning . value ? ? 0 ) ,
delta : overview . value ? . cards . returning . delta _pct ? ? null
}
] )
// 趋势折线:x 轴日期取 MM-DD
const trendDates = computed ( ( ) => ( overview . value ? . trend ? ? [ ] ) . map ( ( p ) => p . date . slice ( 5 ) ) )
const trendSeries = computed ( ( ) => {
const trend = overview . value ? . trend ? ? [ ]
return [
{ name : '浏览量' , data : trend . map ( ( p ) => p . pv ) } ,
{ name : '访客数' , data : trend . map ( ( p ) => p . uv ) } ,
{ name : '会话数' , data : trend . map ( ( p ) => p . sessions ) } ,
{ name : '新访客' , data : trend . map ( ( p ) => p . new _visitors ) }
]
// ---------- 访问趋势双线图(主蓝=访客数 / 青=访问次数) ----------
const trend = useChart ( )
const trendRef = trend . chartRef
const { isDark } = trend
const areaGradient = ( hex : string , topAlpha : number ) =>
new graphic . LinearGradient ( 0 , 0 , 0 , 1 , [
{ offset : 0 , color : ` rgba( ${ hex } , ${ topAlpha } ) ` } ,
{ offset : 1 , color : ` rgba( ${ hex } , 0.01) ` }
] )
const renderTrendChart = ( ) => {
const points = overview . value ? . trend ? ? [ ]
// 全零窗口显示空态(与模板折线图组件同款判定)
const empty = ! points . length || points . every ( ( p ) => p . uv === 0 && p . sessions === 0 )
if ( empty ) {
trend . initChart ( { } , true )
return
}
trend . updateChart ( {
grid : { top : 44 , right : 16 , bottom : 8 , left : 0 , containLabel : true } ,
tooltip : { ... trend . getTooltipStyle ( 'axis' ) } ,
legend : {
right : 0 ,
top : 0 ,
icon : 'roundRect' ,
itemWidth : 14 ,
itemHeight : 4 ,
itemGap : 16 ,
textStyle : { color : isDark . value ? '#fff' : '#4E5969' , fontSize : 12 }
} ,
xAxis : {
type : 'category' ,
boundaryGap : false ,
data : points . map ( ( p ) => p . date . slice ( 5 ) . replace ( '-' , '/' ) ) , // MM/DD
axisTick : trend . getAxisTickStyle ( ) ,
axisLine : trend . getAxisLineStyle ( ) ,
axisLabel : trend . getAxisLabelStyle ( )
} ,
yAxis : {
type : 'value' ,
min : 0 ,
minInterval : 1 ,
axisLabel : trend . getAxisLabelStyle ( ) ,
axisLine : trend . getAxisLineStyle ( false ) ,
splitLine : trend . getSplitLineStyle ( )
} ,
series : [
{
name : '访客数' ,
type : 'line' ,
z : 3 , // 双线数值相等(uv==sessions)时主蓝线画在青线之上,保持"访客数为主"的视觉层级
smooth : true ,
symbol : 'circle' ,
symbolSize : 5 ,
showSymbol : false ,
data : points . map ( ( p ) => p . uv ) ,
color : TREND _COLORS . uv ,
lineStyle : { width : 2.5 } ,
areaStyle : { color : areaGradient ( '37, 99, 235' , 0.18 ) }
} ,
{
name : '访问次数' ,
type : 'line' ,
z : 2 ,
smooth : true ,
symbol : 'circle' ,
symbolSize : 5 ,
showSymbol : false ,
data : points . map ( ( p ) => p . sessions ) ,
color : TREND _COLORS . sessions ,
lineStyle : { width : 2.5 } ,
areaStyle : { color : areaGradient ( '6, 182, 212' , 0.16 ) }
}
]
} )
}
// ---------- 来源分布横向条形图(条长严格按 pct,x 轴定死 0-100) ----------
const channelChart = useChart ( )
const channelRef = channelChart . chartRef
const renderChannelChart = ( ) => {
const channels = overview . value ? . channels ? ? [ ]
if ( ! channels . length ) {
channelChart . initChart ( { } , true )
return
}
const sessionsOf = new Map (
channels . map ( ( c ) => [ channelLabel ( c . channel ) , c . sessions ] )
)
channelChart . updateChart ( {
grid : { top : 8 , right : 52 , bottom : 8 , left : 8 , containLabel : true } ,
tooltip : {
... channelChart . getTooltipStyle ( 'item' ) ,
formatter : ( p : { name : string } ) =>
` ${ p . name } : ${ sessionsOf . get ( p . name ) ? ? 0 } 次会话 `
} ,
xAxis : { type : 'value' , min : 0 , max : 100 , show : false } ,
yAxis : {
type : 'category' ,
inverse : true , // 最大的渠道在最上
data : channels . map ( ( c ) => channelLabel ( c . channel ) ) ,
axisTick : { show : false } ,
axisLine : { show : false } ,
axisLabel : { color : isDark . value ? '#fff' : '#4E5969' , fontSize : 12 }
} ,
series : [
{
type : 'bar' ,
barWidth : 14 ,
data : channels . map ( ( c ) => ( {
value : Number ( ( c . pct * 100 ) . toFixed ( 2 ) ) ,
itemStyle : {
color : CHANNEL _LABELS [ c . channel ] ? . color ? ? '#C0C4CC' ,
borderRadius : [ 0 , 7 , 7 , 0 ]
}
} ) ) ,
label : {
show : true ,
position : 'right' ,
color : isDark . value ? '#fff' : '#4E5969' ,
fontSize : 12 ,
fontWeight : 600 ,
formatter : ( p : { value : unknown } ) => ` ${ Math . round ( Number ( p . value ) ) } % `
}
}
]
} )
}
/** 渠道显示名(CHANNEL_LABELS 已把 direct 标为「直接或未知」;未枚举值原样) */
const channelLabel = ( channel : string ) : string =>
CHANNEL _LABELS [ channel ] ? . label ? ? channel
// 数据到位后重绘两张图(nextTick 等容器就绪;卸载销毁由 useChart 的 onUnmounted 负责)
watch ( overview , ( ) => {
nextTick ( ( ) => {
renderTrendChart ( )
renderChannelChart ( )
} )
} )
// 渠道构成饼图(归因口径):标签/颜色都按渠道绑定取自 CHANNEL_LABELS——
// 颜色随 data 同序映射,渠道缺席时也不会错位到别的渠道色
const channelData = computed ( ( ) =>
( overview . value ? . channels ? ? [ ] ) . map ( ( c ) => ( {
name : CHANNEL _LABELS [ c . channel ] ? . label ? ? c . channel ,
value : c . sessions
} ) )
)
const channelColors = computed ( ( ) =>
( overview . value ? . channels ? ? [ ] ) . map ( ( c ) => CHANNEL _LABELS [ c . channel ] ? . color ? ? '#C0C4CC' )
)
// ---------- 最近访问(前 5 行,既有访客列表接口) ----------
const recentVisitors = ref < VisitorRow [ ] > ( [ ] )
const visitorsLoading = ref ( false )
let visitorsSeq = 0
// 平台明细(原始 referrer 域名口径,与渠道归因互补)
const platformSources = computed ( ( ) => overview . value ? . sources ? ? [ ] )
const loadRecentVisitors = async ( ) => {
const seq = ++ visitorsSeq
visitorsLoading . value = true
try {
const resp = await getVisitors ( {
page : 1 ,
site : selectedSiteId . value === ALL _SITES ? undefined : selectedSiteId . value
} )
if ( seq === visitorsSeq ) recentVisitors . value = ( resp . list ? ? [ ] ) . slice ( 0 , 5 )
} catch {
if ( seq === visitorsSeq ) recentVisitors . value = [ ]
} finally {
if ( seq === visitorsSeq ) visitorsLoading . value = false
}
}
// 热门页面
const topPages = computed ( ( ) => overview . value ? . top _pages ? ? [ ] )
/** 渠道徽章配色:渠道色 → 文字/浅底/描边(与列表/详情同款) */
const badgeStyle = ( row : VisitorRow ) : Record < string , string > => {
const color = CHANNEL _LABELS [ row . channel ] ? . color ? ? CHANNEL _LABELS . unknown . color
return { color , background : ` ${ color } 1A ` , borderColor : ` ${ color } 59 ` }
}
const router = useRouter ( )
const openVisitor = ( row : VisitorRow ) => {
router . push ( { path : '/tracker/visitor/detail' , query : { visitor _id : row . visitor _id } } )
}
const goVisitorList = ( ) => router . push ( '/tracker/visitor' )
// ---------- 生命周期(keepAlive:首次挂载拉取,再次激活刷新数据) ----------
let booted = false
let firstActivation = true
const boot = ( ) => {
if ( booted ) return
booted = true
loadSites ( )
loadOverview ( )
loadRecentVisitors ( )
}
onMounted ( boot )
onActivated ( ( ) => {
if ( firstActivation ) {
firstActivation = false // 首次激活紧随 onMounted, boot 已拉取
return
}
loadOverview ( )
loadRecentVisitors ( )
} )
< / script >
< style lang = "scss" scoped >
// 客迹视觉 V1 色板 token(与访客详情页同款)
$vd - primary : # 2563 eb ;
$vd - primary - bg : # e8f1fe ;
$vd - text : # 1 d2129 ;
$vd - text - 2 : # 4 e5969 ;
$vd - text - 3 : # 86909 c ;
$vd - border : # e9eaf0 ;
. vd - card {
background : var ( -- default - box - color , # fff ) ;
border : 1 px solid $vd - border ;
border - radius : 12 px ;
box - shadow : 0 1 px 3 px rgba ( 29 , 33 , 41 , 0.04 ) ;
}
// 页头
. db - header {
display : flex ;
align - items : flex - end ;
justify - content : space - between ;
flex - wrap : wrap ;
gap : 12 px 20 px ;
margin - bottom : 16 px ;
}
. db - title {
margin : 0 ;
font - size : 24 px ;
font - weight : 700 ;
color : $vd - text ;
line - height : 1.3 ;
}
. db - subtitle {
margin : 6 px 0 0 ;
font - size : 14 px ;
color : $vd - text - 3 ;
}
. db - header - tools {
display : flex ;
align - items : center ;
flex - wrap : wrap ;
gap : 10 px ;
}
// 在线胶囊:灰点离线 / 绿点在线
. db - online {
display : inline - flex ;
align - items : center ;
padding : 5 px 12 px ;
font - size : 13 px ;
color : $vd - text - 2 ;
background : var ( -- default - box - color , # fff ) ;
border : 1 px solid $vd - border ;
border - radius : 999 px ;
gap : 6 px ;
. db - online - dot {
width : 7 px ;
height : 7 px ;
background : var ( -- el - text - color - disabled ) ;
border - radius : 50 % ;
}
& . is - online {
color : var ( -- el - color - success ) ;
. db - online - dot {
background : var ( -- el - color - success ) ;
}
}
}
// 4 周期统计卡
. db - stats {
display : grid ;
grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ;
gap : 16 px ;
margin - bottom : 16 px ;
min - height : 118 px ;
}
. db - stat - card {
display : flex ;
flex - direction : column ;
padding : 18 px 20 px 14 px ;
}
. db - stat - top {
display : flex ;
align - items : center ;
gap : 12 px ;
}
. db - stat - icon {
display : flex ;
width : 44 px ;
height : 44 px ;
flex : none ;
align - items : center ;
justify - content : center ;
color : $vd - primary ;
background : $vd - primary - bg ;
border - radius : 12 px ;
}
. db - stat - label {
font - size : 13 px ;
color : $vd - text - 3 ;
}
. db - stat - value {
margin - top : 10 px ;
overflow : hidden ;
font - size : 26 px ;
font - weight : 700 ;
color : $vd - text ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
// 趋势行(卡右下角):涨绿跌红 / null 灰 —
. db - stat - trend {
display : flex ;
align - items : baseline ;
justify - content : flex - end ;
margin - top : auto ;
padding - top : 8 px ;
gap : 6 px ;
}
. db - delta {
font - size : 13 px ;
font - weight : 700 ;
& . is - up {
color : var ( -- el - color - success ) ;
}
& . is - down {
color : var ( -- el - color - danger ) ;
}
& . is - none {
font - weight : 400 ;
color : $vd - text - 3 ;
}
}
. db - delta - caption {
font - size : 12 px ;
color : $vd - text - 3 ;
}
// 双栏图表行:趋势 2/3 + 来源分布 1/3
. db - charts {
display : grid ;
grid - template - columns : minmax ( 0 , 2 fr ) minmax ( 0 , 1 fr ) ;
gap : 20 px ;
margin - bottom : 16 px ;
}
. db - chart - card {
display : flex ;
flex - direction : column ;
}
. db - card - head {
display : flex ;
align - items : center ;
justify - content : space - between ;
flex - wrap : wrap ;
padding : 16 px 20 px 12 px ;
gap : 8 px 12 px ;
border - bottom : 1 px solid $vd - border ;
}
. db - card - title {
display : flex ;
align - items : center ;
font - size : 15 px ;
font - weight : 600 ;
color : $vd - text ;
gap : 8 px ;
. el - icon {
color : $vd - primary ;
}
}
. db - card - hint {
font - size : 12 px ;
color : $vd - text - 3 ;
}
. db - chart - body {
height : 300 px ;
padding : 12 px 16 px ;
}
// 最近访问表卡
. db - table - card {
padding - bottom : 12 px ;
}
. db - visitor - cell {
display : flex ;
flex - direction : column ;
align - items : flex - start ;
padding : 6 px 0 ;
gap : 4 px ;
}
. db - visitor - code {
padding : 2 px 8 px ;
font - size : 12 px ;
background : var ( -- el - fill - color - light ) ;
border - radius : 4 px ;
color : var ( -- el - text - color - regular ) ;
}
// 渠道徽章胶囊(复用 channelBadge 文案 + 渠道配色)
. db - badge {
display : inline - block ;
max - width : 100 % ;
padding : 2 px 10 px ;
overflow : hidden ;
font - size : 12 px ;
line - height : 18 px ;
border : 1 px solid transparent ;
border - radius : 999 px ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. db - source - text {
font - size : 13 px ;
color : $vd - text - 2 ;
}
. db - path - text {
font - family : var ( -- el - font - family - mono , monospace ) ;
font - size : 12 px ;
color : $vd - text - 2 ;
}
: deep ( . db - clickable - row ) {
cursor : pointer ;
}
// 响应式:中屏图表下沉单栏,小屏统计卡两列
@ media ( max - width : 1200 px ) {
. db - charts {
grid - template - columns : minmax ( 0 , 1 fr ) ;
}
}
@ media ( max - width : 900 px ) {
. db - stats {
grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ;
}
}
< / style >