8f20d8d9a2
Co-Authored-By: Claude Code <noreply@anthropic.com>
714 lines
20 KiB
Vue
714 lines
20 KiB
Vue
<!-- 访客统计 · 数据总览(客迹视觉 V1:周期四卡 + 趋势双线 + 渠道条形 + 最近访问) -->
|
||
<template>
|
||
<div class="tracker-dashboard-page p-5">
|
||
<!-- 页头:标题/副标题 + 在线胶囊 + 站点/周期筛选 -->
|
||
<div class="db-header">
|
||
<div class="db-header-main">
|
||
<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: 220px"
|
||
:loading="sitesLoading"
|
||
@change="onSiteChange"
|
||
>
|
||
<ElOption label="全部站点" value="ALL_SITES" />
|
||
<ElOption
|
||
v-for="site in sites"
|
||
:key="site.id"
|
||
:label="`${site.name}(${site.site_id})`"
|
||
:value="site.site_id"
|
||
/>
|
||
</ElSelect>
|
||
<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>
|
||
|
||
<!-- 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>
|
||
<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="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>
|
||
<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 { 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 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' })
|
||
|
||
// 客迹视觉 V1 图表用色(双线:主蓝 + 青;与详情页色板同源)
|
||
const TREND_COLORS = { uv: '#2563EB', sessions: '#06B6D4' }
|
||
const RANGE_HINTS: Record<OverviewRange, string> = {
|
||
today: '今天',
|
||
'7d': '近 7 天',
|
||
'30d': '近 30 天'
|
||
}
|
||
|
||
// ---------- 站点 / 周期筛选 ----------
|
||
// 哨兵值 ALL_SITES = 全部站点(空串会被 ElSelect 当空值显示 placeholder)
|
||
const ALL_SITES = 'ALL_SITES'
|
||
const sites = ref<SiteRow[]>([])
|
||
const sitesLoading = ref(false)
|
||
const selectedSiteId = ref(ALL_SITES)
|
||
const range = ref<OverviewRange>('7d')
|
||
|
||
const loadSites = async () => {
|
||
sitesLoading.value = true
|
||
try {
|
||
sites.value = await getSites()
|
||
} catch {
|
||
// 失败已由请求层统一 ElMessage 提示,下拉保持"全部站点"
|
||
} finally {
|
||
sitesLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ---------- 总览(周期卡 + 趋势 + 渠道) ----------
|
||
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({
|
||
site: selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value,
|
||
range: range.value
|
||
})
|
||
if (id === reqSeq) overview.value = resp
|
||
} catch {
|
||
// 失败已由请求层统一 ElMessage 提示;overview 置空走空态
|
||
if (id === reqSeq) overview.value = null
|
||
} finally {
|
||
if (id === reqSeq) overviewLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 站点切换:总览与最近访问都按站点过滤,需同时重拉
|
||
const onSiteChange = () => {
|
||
loadOverview()
|
||
loadRecentVisitors()
|
||
}
|
||
|
||
const onlineCount = computed(() => overview.value?.header.online ?? 0)
|
||
|
||
/** 千分位(访客数/访问次数/回访访客) */
|
||
const fmtInt = (n: number) => n.toLocaleString('en-US')
|
||
|
||
// 四张周期卡:值 + 环比(delta 为 null 显示灰 —,负值红 ↓)
|
||
const statCards = computed<{ label: string; icon: Component; value: string; delta: number | null }[]>(() => [
|
||
{
|
||
label: '访客数',
|
||
icon: markRaw(UserIcon),
|
||
value: fmtInt(overview.value?.cards.visitors.value ?? 0),
|
||
delta: overview.value?.cards.visitors.delta_pct ?? null
|
||
},
|
||
{
|
||
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
|
||
}
|
||
])
|
||
|
||
// ---------- 访问趋势双线图(主蓝=访客数 / 青=访问次数) ----------
|
||
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()
|
||
})
|
||
})
|
||
|
||
// ---------- 最近访问(前 5 行,既有访客列表接口) ----------
|
||
const recentVisitors = ref<VisitorRow[]>([])
|
||
const visitorsLoading = ref(false)
|
||
let visitorsSeq = 0
|
||
|
||
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 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: #2563eb;
|
||
$vd-primary-bg: #e8f1fe;
|
||
$vd-text: #1d2129;
|
||
$vd-text-2: #4e5969;
|
||
$vd-text-3: #86909c;
|
||
$vd-border: #e9eaf0;
|
||
|
||
.vd-card {
|
||
background: var(--default-box-color, #fff);
|
||
border: 1px solid $vd-border;
|
||
border-radius: 12px;
|
||
box-shadow: 0 1px 3px rgba(29, 33, 41, 0.04);
|
||
}
|
||
|
||
// 页头
|
||
.db-header {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 12px 20px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.db-title {
|
||
margin: 0;
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: $vd-text;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.db-subtitle {
|
||
margin: 6px 0 0;
|
||
font-size: 14px;
|
||
color: $vd-text-3;
|
||
}
|
||
|
||
.db-header-tools {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
// 在线胶囊:灰点离线 / 绿点在线
|
||
.db-online {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 5px 12px;
|
||
font-size: 13px;
|
||
color: $vd-text-2;
|
||
background: var(--default-box-color, #fff);
|
||
border: 1px solid $vd-border;
|
||
border-radius: 999px;
|
||
gap: 6px;
|
||
|
||
.db-online-dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
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, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
min-height: 118px;
|
||
}
|
||
|
||
.db-stat-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 18px 20px 14px;
|
||
}
|
||
|
||
.db-stat-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.db-stat-icon {
|
||
display: flex;
|
||
width: 44px;
|
||
height: 44px;
|
||
flex: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: $vd-primary;
|
||
background: $vd-primary-bg;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.db-stat-label {
|
||
font-size: 13px;
|
||
color: $vd-text-3;
|
||
}
|
||
|
||
.db-stat-value {
|
||
margin-top: 10px;
|
||
overflow: hidden;
|
||
font-size: 26px;
|
||
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: 8px;
|
||
gap: 6px;
|
||
}
|
||
|
||
.db-delta {
|
||
font-size: 13px;
|
||
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: 12px;
|
||
color: $vd-text-3;
|
||
}
|
||
|
||
// 双栏图表行:趋势 2/3 + 来源分布 1/3
|
||
.db-charts {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||
gap: 20px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.db-chart-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.db-card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
padding: 16px 20px 12px;
|
||
gap: 8px 12px;
|
||
border-bottom: 1px solid $vd-border;
|
||
}
|
||
|
||
.db-card-title {
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: $vd-text;
|
||
gap: 8px;
|
||
|
||
.el-icon {
|
||
color: $vd-primary;
|
||
}
|
||
}
|
||
|
||
.db-card-hint {
|
||
font-size: 12px;
|
||
color: $vd-text-3;
|
||
}
|
||
|
||
.db-chart-body {
|
||
height: 300px;
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
// 最近访问表卡
|
||
.db-table-card {
|
||
padding-bottom: 12px;
|
||
}
|
||
|
||
.db-visitor-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
padding: 6px 0;
|
||
gap: 4px;
|
||
}
|
||
|
||
.db-visitor-code {
|
||
padding: 2px 8px;
|
||
font-size: 12px;
|
||
background: var(--el-fill-color-light);
|
||
border-radius: 4px;
|
||
color: var(--el-text-color-regular);
|
||
}
|
||
|
||
// 渠道徽章胶囊(复用 channelBadge 文案 + 渠道配色)
|
||
.db-badge {
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
padding: 2px 10px;
|
||
overflow: hidden;
|
||
font-size: 12px;
|
||
line-height: 18px;
|
||
border: 1px solid transparent;
|
||
border-radius: 999px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.db-source-text {
|
||
font-size: 13px;
|
||
color: $vd-text-2;
|
||
}
|
||
|
||
.db-path-text {
|
||
font-family: var(--el-font-family-mono, monospace);
|
||
font-size: 12px;
|
||
color: $vd-text-2;
|
||
}
|
||
|
||
:deep(.db-clickable-row) {
|
||
cursor: pointer;
|
||
}
|
||
|
||
// 响应式:中屏图表下沉单栏,小屏统计卡两列
|
||
@media (max-width: 1200px) {
|
||
.db-charts {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.db-stats {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
</style>
|