feat(admin): 来源归因展示——渠道徽章/筛选/来源识别卡/渠道构成图
- 类型:VisitorRow 补 channel/platform/variant/search_term/resolution,新增 AttributionInfo/AttributionBlock,详情响应带 attribution 块;dashboard 响应补 channels(ChannelItem) - 工具:CHANNEL_LABELS 八渠道元数据 + channelBadge 徽章串(搜索词/标签/ 冲突后缀,未归因回 —) - 访客列表:首触来源列升级渠道徽章(first_source 进 tooltip 兜底), 筛选栏加渠道下拉(channel= 枚举) - 访客详情:首触来源升级来源识别卡(渠道徽章/平台/国家站/搜索词显著 展示,label_only/conflict 诚实标注,冲突并列两路证据文本渲染), 无归因旧数据降级原 first_source 口径 - 总览:来源饼图改渠道构成(标签/颜色按渠道绑定 CHANNEL_LABELS), 下方保留平台明细小表(原 sources 域名口径) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,13 @@ export interface SourceItem {
|
|||||||
sessions: number
|
sessions: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 今日渠道构成 TOP8(session_entry 分类聚合,sessions 去重计数;
|
||||||
|
* channel 为 CHANNELS 枚举,无分类行不计入——未重算的历史会话不在此口径内) */
|
||||||
|
export interface ChannelItem {
|
||||||
|
channel: string
|
||||||
|
sessions: number
|
||||||
|
}
|
||||||
|
|
||||||
/** 今日热门页面 TOP8(page_view 按 path 聚合) */
|
/** 今日热门页面 TOP8(page_view 按 path 聚合) */
|
||||||
export interface PageItem {
|
export interface PageItem {
|
||||||
path: string
|
path: string
|
||||||
@@ -42,6 +49,8 @@ export interface OverviewResp {
|
|||||||
today: TodayCounts
|
today: TodayCounts
|
||||||
trend: TrendPoint[]
|
trend: TrendPoint[]
|
||||||
sources: SourceItem[]
|
sources: SourceItem[]
|
||||||
|
/** 来源渠道聚合(sources 的归因升级版,原始域名口径保留在 sources) */
|
||||||
|
channels: ChannelItem[]
|
||||||
top_pages: PageItem[]
|
top_pages: PageItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export interface VisitorRow {
|
|||||||
total_stay_ms: number
|
total_stay_ms: number
|
||||||
first_source: string
|
first_source: string
|
||||||
last_source: string
|
last_source: string
|
||||||
|
/** 首触来源归因(first_touch 分类;无分类行/未重算的历史访客为 '',前端回落 first_source 展示) */
|
||||||
|
channel: string
|
||||||
|
platform: string
|
||||||
|
variant: string
|
||||||
|
search_term: string
|
||||||
|
resolution: string
|
||||||
online: boolean
|
online: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +51,35 @@ export interface SessionSummary {
|
|||||||
stay_ms: number
|
stay_ms: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单条来源识别(visitors.py `_classification`:classify() 结果 + 规则版本/计算时间) */
|
||||||
|
export interface AttributionInfo {
|
||||||
|
/** CHANNELS 枚举:natural_search/paid_search/ai_referral/social/in_app/referral/direct/unknown */
|
||||||
|
channel: string
|
||||||
|
/** 平台显示名(Google/ChatGPT/微信…;referral 渠道为来源域名) */
|
||||||
|
platform: string
|
||||||
|
/** 国家站变体(如 google.ru 的 "ru") */
|
||||||
|
variant: string
|
||||||
|
/** 搜索词(仅自然搜索可能有值) */
|
||||||
|
search_term: string
|
||||||
|
/** observed_referrer / utm_label / combined / observed_ua */
|
||||||
|
evidence_kind: string
|
||||||
|
/** resolved / label_only / conflict / unknown */
|
||||||
|
resolution: string
|
||||||
|
/** 规则侧附加证据(conflict 时含 referrer_evidence/utm_evidence 并列)——只做文本渲染,禁 v-html */
|
||||||
|
detail: Record<string, unknown>
|
||||||
|
/** 生成该分类的规则版本(attribution.py RULE_VERSION) */
|
||||||
|
rule_version: string
|
||||||
|
computed_at: string
|
||||||
|
/** 仅 session_entries 携带(first_touch 无会话归属,不带该键) */
|
||||||
|
session_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情归因块:first_touch 主口径 + 近几次会话入口识别(旧数据未重算时 first_touch 为 null) */
|
||||||
|
export interface AttributionBlock {
|
||||||
|
first_touch: AttributionInfo | null
|
||||||
|
session_entries: AttributionInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
/** 访客详情(`_row` + 详情扩展字段 + 全量会话摘要,M1 不分页) */
|
/** 访客详情(`_row` + 详情扩展字段 + 全量会话摘要,M1 不分页) */
|
||||||
export interface VisitorDetail extends VisitorRow {
|
export interface VisitorDetail extends VisitorRow {
|
||||||
screen: string
|
screen: string
|
||||||
@@ -54,6 +89,8 @@ export interface VisitorDetail extends VisitorRow {
|
|||||||
first_utm: Record<string, string>
|
first_utm: Record<string, string>
|
||||||
first_landing_path: string
|
first_landing_path: string
|
||||||
first_referrer_domain: string
|
first_referrer_domain: string
|
||||||
|
/** 来源归因块(first_touch=null 时前端回落 first_source 展示) */
|
||||||
|
attribution: AttributionBlock
|
||||||
sessions: SessionSummary[]
|
sessions: SessionSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,46 @@ const COUNTRY_FLAGS: Record<string, string> = {
|
|||||||
export const countryFlag = (country?: string | null): string =>
|
export const countryFlag = (country?: string | null): string =>
|
||||||
(country && COUNTRY_FLAGS[country]) || '🌐'
|
(country && COUNTRY_FLAGS[country]) || '🌐'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 来源渠道展示元数据(key 与后端 CHANNELS 枚举一一对应,见 server/tracker/constants.py)。
|
||||||
|
* icon 走 emoji;color 用于渠道徽章底色/图表取色(列表/详情/总览三处共用一套口径)。
|
||||||
|
*/
|
||||||
|
export const CHANNEL_LABELS: Record<string, { label: string; icon: string; color: string }> = {
|
||||||
|
natural_search: { label: '自然搜索', icon: '🔍', color: '#409EFF' },
|
||||||
|
paid_search: { label: '付费广告', icon: '💰', color: '#F56C6C' },
|
||||||
|
ai_referral: { label: 'AI 引用', icon: '🤖', color: '#9B59B6' },
|
||||||
|
social: { label: '社交媒体', icon: '💬', color: '#E6A23C' },
|
||||||
|
in_app: { label: '应用内', icon: '📱', color: '#67C23A' },
|
||||||
|
referral: { label: '外部链接', icon: '🔗', color: '#909399' },
|
||||||
|
direct: { label: '直接访问', icon: '⏱', color: '#C0C4CC' },
|
||||||
|
unknown: { label: '未知', icon: '❓', color: '#C0C4CC' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** channelBadge 入参字段(VisitorRow / AttributionInfo 的共同子集) */
|
||||||
|
export interface ChannelBadgeRow {
|
||||||
|
channel: string
|
||||||
|
platform: string
|
||||||
|
variant: string
|
||||||
|
search_term: string
|
||||||
|
resolution: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渠道徽章显示串:icon + platform(·variant),无 platform 时退渠道名;
|
||||||
|
* 有搜索词附加「词」;label_only / conflict 后缀诚实标注;channel 空(未归因)返回 —。
|
||||||
|
* 例:"🔍 Google·ru"、"🤖 ChatGPT"、"📱 微信"、"🔍 百度「挖掘机」"、"⏱ 直接访问"
|
||||||
|
*/
|
||||||
|
export function channelBadge(row: ChannelBadgeRow): string {
|
||||||
|
if (!row.channel) return '—'
|
||||||
|
const meta = CHANNEL_LABELS[row.channel] ?? CHANNEL_LABELS.unknown
|
||||||
|
const platform = [row.platform, row.variant].filter(Boolean).join('·')
|
||||||
|
let text = `${meta.icon} ${platform || meta.label}`
|
||||||
|
if (row.search_term) text += `「${row.search_term}」`
|
||||||
|
if (row.resolution === 'label_only') text += '(标签)'
|
||||||
|
else if (row.resolution === 'conflict') text += '(冲突)'
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
/** ISO 时间 → YYYY-MM-DD HH:mm(后端已是上海时间;空值显示 —) */
|
/** ISO 时间 → YYYY-MM-DD HH:mm(后端已是上海时间;空值显示 —) */
|
||||||
export const formatDateTime = (iso?: string | null): string =>
|
export const formatDateTime = (iso?: string | null): string =>
|
||||||
iso ? iso.replace('T', ' ').slice(0, 16) : '—'
|
iso ? iso.replace('T', ' ').slice(0, 16) : '—'
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
<span class="text-xs text-g-500">今日指标为上海自然日口径,已排除爬虫</span>
|
<span class="text-xs text-g-500">今日指标为上海自然日口径,已排除爬虫</span>
|
||||||
<div class="ml-auto flex-c gap-2">
|
<div class="ml-auto flex-c gap-2">
|
||||||
<span class="text-sm text-g-600">站点</span>
|
<span class="text-sm text-g-600">站点</span>
|
||||||
|
<!-- 宽度用内联 style:EP 未分层 width 规则会压过 Tailwind utilities 层的 w-* 类 -->
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-model="selectedSiteId"
|
v-model="selectedSiteId"
|
||||||
class="w-64"
|
style="width: 256px"
|
||||||
:loading="sitesLoading"
|
:loading="sitesLoading"
|
||||||
@change="loadOverview"
|
@change="loadOverview"
|
||||||
>
|
>
|
||||||
@@ -67,17 +68,32 @@
|
|||||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
<div class="art-card px-5 py-4">
|
<div class="art-card px-5 py-4">
|
||||||
<div class="flex-c flex-wrap gap-2 mb-2">
|
<div class="flex-c flex-wrap gap-2 mb-2">
|
||||||
<h3 class="m-0 text-sm font-semibold">来源构成(今日 TOP8)</h3>
|
<h3 class="m-0 text-sm font-semibold">渠道构成(今日 TOP8)</h3>
|
||||||
<span class="text-xs text-g-500">按会话数统计,无referrer 归为"直接访问"</span>
|
<span class="text-xs text-g-500">按归因规则聚合会话数,未重算的历史会话不计入</span>
|
||||||
</div>
|
</div>
|
||||||
<ArtRingChart
|
<ArtRingChart
|
||||||
:data="sourceData"
|
:data="channelData"
|
||||||
:colors="pieColors"
|
:colors="channelColors"
|
||||||
:loading="overviewLoading"
|
:loading="overviewLoading"
|
||||||
height="20rem"
|
height="15rem"
|
||||||
show-legend
|
show-legend
|
||||||
legend-position="right"
|
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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="art-card px-5 py-4" v-loading="overviewLoading">
|
<div class="art-card px-5 py-4" v-loading="overviewLoading">
|
||||||
@@ -104,22 +120,13 @@
|
|||||||
import { useChartOps } from '@/hooks/core/useChart'
|
import { useChartOps } from '@/hooks/core/useChart'
|
||||||
import { getSites, type SiteRow } from '@/api/tracker/sites'
|
import { getSites, type SiteRow } from '@/api/tracker/sites'
|
||||||
import { getOverview, type OverviewResp } from '@/api/tracker/dashboard'
|
import { getOverview, type OverviewResp } from '@/api/tracker/dashboard'
|
||||||
|
import { CHANNEL_LABELS } from '@/utils/tracker'
|
||||||
|
|
||||||
defineOptions({ name: 'TrackerDashboard' })
|
defineOptions({ name: 'TrackerDashboard' })
|
||||||
|
|
||||||
/** 图表色板:模板主题色为主,剔除过浅色与重复色(useChartOps 默认第 3 位 #EDF2FF 在浅色底不可见) */
|
/** 图表色板:模板主题色为主,剔除过浅色与重复色(useChartOps 默认第 3 位 #EDF2FF 在浅色底不可见) */
|
||||||
const themeColor = useChartOps().themeColor
|
const themeColor = useChartOps().themeColor
|
||||||
const lineColors = [themeColor, '#4ABEFF', '#14DEBA', '#FFAF20']
|
const lineColors = [themeColor, '#4ABEFF', '#14DEBA', '#FFAF20']
|
||||||
const pieColors = [
|
|
||||||
themeColor,
|
|
||||||
'#4ABEFF',
|
|
||||||
'#14DEBA',
|
|
||||||
'#FFAF20',
|
|
||||||
'#FA8A6C',
|
|
||||||
'#67C23A',
|
|
||||||
'#E6A23C',
|
|
||||||
'#9AA5CE'
|
|
||||||
]
|
|
||||||
|
|
||||||
// 站点筛选(哨兵值 ALL_SITES = 全部站点;空串会被 ElSelect 当空值显示 placeholder)
|
// 站点筛选(哨兵值 ALL_SITES = 全部站点;空串会被 ElSelect 当空值显示 placeholder)
|
||||||
const ALL_SITES = 'ALL_SITES'
|
const ALL_SITES = 'ALL_SITES'
|
||||||
@@ -195,10 +202,20 @@
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
// 来源饼图("直接访问"由后端天然归并)
|
// 渠道构成饼图(归因口径):标签/颜色都按渠道绑定取自 CHANNEL_LABELS——
|
||||||
const sourceData = computed(() =>
|
// 颜色随 data 同序映射,渠道缺席时也不会错位到别的渠道色
|
||||||
(overview.value?.sources ?? []).map((s) => ({ name: s.source, value: s.sessions }))
|
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')
|
||||||
|
)
|
||||||
|
|
||||||
|
// 平台明细(原始 referrer 域名口径,与渠道归因互补)
|
||||||
|
const platformSources = computed(() => overview.value?.sources ?? [])
|
||||||
|
|
||||||
// 热门页面
|
// 热门页面
|
||||||
const topPages = computed(() => overview.value?.top_pages ?? [])
|
const topPages = computed(() => overview.value?.top_pages ?? [])
|
||||||
|
|||||||
@@ -57,8 +57,56 @@
|
|||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</ElDescriptionsItem>
|
</ElDescriptionsItem>
|
||||||
<ElDescriptionsItem label="首触来源">
|
<ElDescriptionsItem label="首触来源" :span="2">
|
||||||
<div class="flex-c flex-wrap gap-1.5">
|
<!-- 来源识别卡:归因引擎结论(渠道徽章/平台/国家站/搜索词/可信度标注) -->
|
||||||
|
<div v-if="firstTouch" class="source-card">
|
||||||
|
<div class="flex-c flex-wrap gap-x-2 gap-y-1.5">
|
||||||
|
<span
|
||||||
|
class="channel-tag"
|
||||||
|
:style="{
|
||||||
|
color: firstTouchMeta.color,
|
||||||
|
background: firstTouchMeta.color + '1A',
|
||||||
|
borderColor: firstTouchMeta.color + '59'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ firstTouchMeta.icon }} {{ firstTouchMeta.label }}
|
||||||
|
</span>
|
||||||
|
<span v-if="firstTouch.platform" class="font-medium whitespace-nowrap">
|
||||||
|
{{ firstTouch.platform }}
|
||||||
|
</span>
|
||||||
|
<span v-if="firstTouch.variant" class="text-xs text-g-500 whitespace-nowrap">
|
||||||
|
{{ firstTouch.variant }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="firstTouch.resolution === 'label_only'"
|
||||||
|
class="text-xs text-g-500 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
仅UTM标签
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="firstTouch.resolution === 'conflict'"
|
||||||
|
class="text-xs source-conflict whitespace-nowrap"
|
||||||
|
>
|
||||||
|
证据冲突
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="firstTouch.search_term" class="search-term-row">
|
||||||
|
<span class="shrink-0 text-xs text-g-600">搜索词</span>
|
||||||
|
<span class="search-term">{{ firstTouch.search_term }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 冲突时并列展示两路证据(文本插值渲染,禁 v-html) -->
|
||||||
|
<div
|
||||||
|
v-if="firstTouch.resolution === 'conflict' && conflictEvidences.length"
|
||||||
|
class="conflict-box"
|
||||||
|
>
|
||||||
|
<div v-for="(ev, i) in conflictEvidences" :key="i" class="conflict-line">
|
||||||
|
<span class="shrink-0 text-xs text-g-600">{{ ev.label }}</span>
|
||||||
|
<span class="text-xs">{{ ev.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 降级:无归因(旧数据未重算)→ 原 first_source 口径 + UTM 标签 -->
|
||||||
|
<div v-else class="flex-c flex-wrap gap-1.5">
|
||||||
<span class="whitespace-nowrap">{{ firstSourceText }}</span>
|
<span class="whitespace-nowrap">{{ firstSourceText }}</span>
|
||||||
<ElTag
|
<ElTag
|
||||||
v-for="(val, key) in detail.first_utm"
|
v-for="(val, key) in detail.first_utm"
|
||||||
@@ -368,6 +416,7 @@
|
|||||||
getSession,
|
getSession,
|
||||||
getTimeline,
|
getTimeline,
|
||||||
getVisitor,
|
getVisitor,
|
||||||
|
type AttributionInfo,
|
||||||
type RawBatchRow,
|
type RawBatchRow,
|
||||||
type SessionSummary,
|
type SessionSummary,
|
||||||
type TimelineEvent,
|
type TimelineEvent,
|
||||||
@@ -375,7 +424,7 @@
|
|||||||
type TimelineSession,
|
type TimelineSession,
|
||||||
type VisitorDetail
|
type VisitorDetail
|
||||||
} from '@/api/tracker/visitors'
|
} from '@/api/tracker/visitors'
|
||||||
import { countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
|
import { CHANNEL_LABELS, countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
|
||||||
import EventTimeline from './components/EventTimeline.vue'
|
import EventTimeline from './components/EventTimeline.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'TrackerVisitorDetail' })
|
defineOptions({ name: 'TrackerVisitorDetail' })
|
||||||
@@ -435,6 +484,48 @@
|
|||||||
return d.first_referrer_domain || d.first_utm?.utm_source || '直接访问'
|
return d.first_referrer_domain || d.first_utm?.utm_source || '直接访问'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------- 来源识别卡(归因引擎结论;旧数据未重算时降级上面的 first_source 口径) ----------
|
||||||
|
/** detail 字段类型收窄:归因 detail 是后端拼的 dict,只接受纯对象 */
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||||
|
|
||||||
|
/** 证据记录 → 一行可读文本:渠道 · 平台(· 变体)+ referrer 域名括注 */
|
||||||
|
const formatEvidence = (e: Record<string, unknown>, withHost: boolean): string => {
|
||||||
|
const segs = [
|
||||||
|
CHANNEL_LABELS[String(e.channel ?? '')]?.label || String(e.channel ?? ''),
|
||||||
|
String(e.platform ?? ''),
|
||||||
|
String(e.variant ?? '')
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
const host = withHost && e.referrer_host ? `(${String(e.referrer_host)})` : ''
|
||||||
|
return `${segs}${host}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首触归因(attribution.first_touch;无分类行时后端给 null → 模板走降级分支) */
|
||||||
|
const firstTouch = computed<AttributionInfo | null>(
|
||||||
|
() => detail.value?.attribution?.first_touch ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 渠道徽章元数据(未知渠道值兜底 unknown 项) */
|
||||||
|
const firstTouchMeta = computed(
|
||||||
|
() => CHANNEL_LABELS[firstTouch.value?.channel ?? ''] ?? CHANNEL_LABELS.unknown
|
||||||
|
)
|
||||||
|
|
||||||
|
/** conflict 时并列的两路证据(referrer 观察 vs UTM 标签),文本插值渲染 */
|
||||||
|
const conflictEvidences = computed(() => {
|
||||||
|
const d = firstTouch.value?.detail
|
||||||
|
if (!isRecord(d)) return []
|
||||||
|
const out: { label: string; text: string }[] = []
|
||||||
|
if (isRecord(d.referrer_evidence)) {
|
||||||
|
out.push({ label: '观察证据', text: formatEvidence(d.referrer_evidence, true) })
|
||||||
|
}
|
||||||
|
if (isRecord(d.utm_evidence)) {
|
||||||
|
out.push({ label: '标签证据', text: formatEvidence(d.utm_evidence, false) })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
/** 统计数字 4 项(?? 只兜 nullish,0 正常显示) */
|
/** 统计数字 4 项(?? 只兜 nullish,0 正常显示) */
|
||||||
const statTiles = computed(() => [
|
const statTiles = computed(() => [
|
||||||
{ label: '访问次数', value: detail.value ? String(detail.value.visit_count) : '—' },
|
{ label: '访问次数', value: detail.value ? String(detail.value.visit_count) : '—' },
|
||||||
@@ -649,6 +740,55 @@
|
|||||||
font-family: var(--el-font-family-mono, monospace);
|
font-family: var(--el-font-family-mono, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 来源识别卡(首触归因结论)
|
||||||
|
.source-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-term-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-term {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-conflict {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
|
||||||
|
.conflict-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.stat-tile {
|
.stat-tile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<!-- 访客统计 · 访客列表 -->
|
<!-- 访客统计 · 访客列表 -->
|
||||||
<!-- 筛选区外置独立卡片(dashboard 同款),避免挤占 ArtTable 的高度计算预算 -->
|
<!-- 筛选区外置独立卡片(dashboard 同款),避免挤占 ArtTable 的高度计算预算 -->
|
||||||
|
<!-- 控件宽度用内联 style:EP 未分层 width 规则会压过 Tailwind utilities 层的 w-* 类 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="visitor-page art-full-height">
|
<div class="visitor-page art-full-height">
|
||||||
<!-- 筛选栏 -->
|
<!-- 筛选栏 -->
|
||||||
<div class="art-card flex-c flex-wrap gap-x-4 gap-y-3 mb-3 px-5 py-4">
|
<div class="art-card flex-c flex-wrap gap-x-3 gap-y-2 mb-3 px-5 py-4">
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-model="filters.site"
|
v-model="filters.site"
|
||||||
class="w-52"
|
style="width: 160px"
|
||||||
:loading="sitesLoading"
|
:loading="sitesLoading"
|
||||||
@change="applyFilters"
|
@change="applyFilters"
|
||||||
>
|
>
|
||||||
@@ -20,15 +21,15 @@
|
|||||||
</ElSelect>
|
</ElSelect>
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="filters.search"
|
v-model="filters.search"
|
||||||
class="w-60"
|
style="width: 192px"
|
||||||
placeholder="访客 ID / IP 前缀"
|
placeholder="访客 ID / IP"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="applyFilters"
|
@keyup.enter="applyFilters"
|
||||||
@clear="applyFilters"
|
@clear="applyFilters"
|
||||||
/>
|
/>
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-model="filters.deviceType"
|
v-model="filters.deviceType"
|
||||||
class="w-32"
|
style="width: 112px"
|
||||||
placeholder="全部设备"
|
placeholder="全部设备"
|
||||||
clearable
|
clearable
|
||||||
@change="applyFilters"
|
@change="applyFilters"
|
||||||
@@ -37,10 +38,24 @@
|
|||||||
<ElOption label="手机" value="手机" />
|
<ElOption label="手机" value="手机" />
|
||||||
<ElOption label="平板" value="平板" />
|
<ElOption label="平板" value="平板" />
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
|
<ElSelect
|
||||||
|
v-model="filters.channel"
|
||||||
|
style="width: 136px"
|
||||||
|
placeholder="全部渠道"
|
||||||
|
clearable
|
||||||
|
@change="applyFilters"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="opt in channelOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="filters.country"
|
v-model="filters.country"
|
||||||
class="w-44"
|
style="width: 128px"
|
||||||
placeholder="国家(精确匹配)"
|
placeholder="国家(精确)"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="applyFilters"
|
@keyup.enter="applyFilters"
|
||||||
@clear="applyFilters"
|
@clear="applyFilters"
|
||||||
@@ -52,7 +67,7 @@
|
|||||||
<ElDatePicker
|
<ElDatePicker
|
||||||
v-model="filters.dateRange"
|
v-model="filters.dateRange"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
class="w-64"
|
style="width: 224px"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
range-separator="至"
|
range-separator="至"
|
||||||
start-placeholder="最近访问起"
|
start-placeholder="最近访问起"
|
||||||
@@ -95,11 +110,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElButton } from 'element-plus'
|
import { ElButton, ElTooltip } from 'element-plus'
|
||||||
import { useTable } from '@/hooks/core/useTable'
|
import { useTable } from '@/hooks/core/useTable'
|
||||||
import { getVisitors, type VisitorListParams, type VisitorRow } from '@/api/tracker/visitors'
|
import { getVisitors, type VisitorListParams, type VisitorRow } from '@/api/tracker/visitors'
|
||||||
import { getSites, type SiteRow } from '@/api/tracker/sites'
|
import { getSites, type SiteRow } from '@/api/tracker/sites'
|
||||||
import { countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
|
import {
|
||||||
|
CHANNEL_LABELS,
|
||||||
|
channelBadge,
|
||||||
|
countryFlag,
|
||||||
|
formatDateTime,
|
||||||
|
formatStay
|
||||||
|
} from '@/utils/tracker'
|
||||||
|
|
||||||
defineOptions({ name: 'TrackerVisitor' })
|
defineOptions({ name: 'TrackerVisitor' })
|
||||||
|
|
||||||
@@ -142,12 +163,19 @@
|
|||||||
site: ALL_SITES,
|
site: ALL_SITES,
|
||||||
search: '',
|
search: '',
|
||||||
deviceType: '',
|
deviceType: '',
|
||||||
|
channel: '',
|
||||||
country: '',
|
country: '',
|
||||||
online: false,
|
online: false,
|
||||||
showBot: false,
|
showBot: false,
|
||||||
dateRange: null as [string, string] | null
|
dateRange: null as [string, string] | null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 渠道下拉选项:与后端 CHANNELS 枚举同序(CHANNEL_LABELS 的 key 顺序) */
|
||||||
|
const channelOptions = Object.entries(CHANNEL_LABELS).map(([value, meta]) => ({
|
||||||
|
value,
|
||||||
|
label: `${meta.icon} ${meta.label}`
|
||||||
|
}))
|
||||||
|
|
||||||
/** 组装请求参数:online/show_bot 用后端哨兵 '1'(类型已锁字面量),空值不发送 */
|
/** 组装请求参数:online/show_bot 用后端哨兵 '1'(类型已锁字面量),空值不发送 */
|
||||||
const buildFilterParams = (): VisitorListParams => {
|
const buildFilterParams = (): VisitorListParams => {
|
||||||
const params: VisitorListParams = {}
|
const params: VisitorListParams = {}
|
||||||
@@ -155,6 +183,7 @@
|
|||||||
const search = filters.search.trim()
|
const search = filters.search.trim()
|
||||||
if (search) params.search = search
|
if (search) params.search = search
|
||||||
if (filters.deviceType) params.device_type = filters.deviceType
|
if (filters.deviceType) params.device_type = filters.deviceType
|
||||||
|
if (filters.channel) params.channel = filters.channel
|
||||||
const country = filters.country.trim()
|
const country = filters.country.trim()
|
||||||
if (country) params.country = country
|
if (country) params.country = country
|
||||||
if (filters.online) params.online = '1'
|
if (filters.online) params.online = '1'
|
||||||
@@ -223,6 +252,18 @@
|
|||||||
|
|
||||||
const renderSource = (val: string) => h('span', { class: 'whitespace-nowrap' }, val || '—')
|
const renderSource = (val: string) => h('span', { class: 'whitespace-nowrap' }, val || '—')
|
||||||
|
|
||||||
|
/** 首触来源:有归因走渠道徽章(icon+platform/variant+搜索词+诚实后缀),first_source 原文进 tooltip 兜底 */
|
||||||
|
const renderFirstSource = (row: VisitorRow) => {
|
||||||
|
if (!row.channel) return renderSource(row.first_source)
|
||||||
|
return h(
|
||||||
|
ElTooltip,
|
||||||
|
{ content: row.first_source || '—', placement: 'top' },
|
||||||
|
{
|
||||||
|
default: () => h('span', { class: 'whitespace-nowrap' }, channelBadge(row))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderSite = (row: VisitorRow) =>
|
const renderSite = (row: VisitorRow) =>
|
||||||
h('span', { class: 'whitespace-nowrap' }, row.site_name || '—')
|
h('span', { class: 'whitespace-nowrap' }, row.site_name || '—')
|
||||||
|
|
||||||
@@ -297,8 +338,8 @@
|
|||||||
{
|
{
|
||||||
prop: 'first_source',
|
prop: 'first_source',
|
||||||
label: '首触来源',
|
label: '首触来源',
|
||||||
minWidth: 110,
|
minWidth: 150,
|
||||||
formatter: (row: VisitorRow) => renderSource(row.first_source)
|
formatter: (row: VisitorRow) => renderFirstSource(row)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prop: 'last_source',
|
prop: 'last_source',
|
||||||
@@ -327,6 +368,7 @@
|
|||||||
site: ALL_SITES,
|
site: ALL_SITES,
|
||||||
search: '',
|
search: '',
|
||||||
deviceType: '',
|
deviceType: '',
|
||||||
|
channel: '',
|
||||||
country: '',
|
country: '',
|
||||||
online: false,
|
online: false,
|
||||||
showBot: false,
|
showBot: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user