feat: 访客列表站点归属显示与筛选
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,9 @@ import request from '@/utils/http'
|
|||||||
/** 访客行(visitors.py `_row`:列表/详情共用行字段) */
|
/** 访客行(visitors.py `_row`:列表/详情共用行字段) */
|
||||||
export interface VisitorRow {
|
export interface VisitorRow {
|
||||||
visitor_id: string
|
visitor_id: string
|
||||||
|
/** 站点归属(多站点下分辨访客来自哪个站) */
|
||||||
|
site_id: string
|
||||||
|
site_name: string
|
||||||
country: string
|
country: string
|
||||||
province: string
|
province: string
|
||||||
city: string
|
city: string
|
||||||
@@ -97,6 +100,8 @@ export interface VisitorListResp {
|
|||||||
*/
|
*/
|
||||||
export interface VisitorListParams {
|
export interface VisitorListParams {
|
||||||
page?: number
|
page?: number
|
||||||
|
/** 站点过滤(site_id;不传 = 全部站点,站点不存在时后端 404 site_not_found) */
|
||||||
|
site?: string
|
||||||
country?: string
|
country?: string
|
||||||
device_type?: string
|
device_type?: string
|
||||||
/** 仅 "1" 生效:5 分钟内有行为的在线访客 */
|
/** 仅 "1" 生效:5 分钟内有行为的在线访客 */
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
<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-4 gap-y-3 mb-3 px-5 py-4">
|
||||||
|
<ElSelect
|
||||||
|
v-model="filters.site"
|
||||||
|
class="w-52"
|
||||||
|
:loading="sitesLoading"
|
||||||
|
@change="applyFilters"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="filters.search"
|
v-model="filters.search"
|
||||||
class="w-60"
|
class="w-60"
|
||||||
@@ -84,6 +98,7 @@
|
|||||||
import { ElButton } from 'element-plus'
|
import { ElButton } 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 { countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
|
import { countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
|
||||||
|
|
||||||
defineOptions({ name: 'TrackerVisitor' })
|
defineOptions({ name: 'TrackerVisitor' })
|
||||||
@@ -107,7 +122,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 筛选 ----------
|
// ---------- 筛选 ----------
|
||||||
|
// 站点筛选(哨兵值 ALL_SITES = 全部站点,请求时映射为不传 site;与 dashboard 同款模式)
|
||||||
|
const ALL_SITES = 'ALL_SITES'
|
||||||
|
const sites = ref<SiteRow[]>([])
|
||||||
|
const sitesLoading = ref(false)
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
sitesLoading.value = true
|
||||||
|
try {
|
||||||
|
sites.value = await getSites()
|
||||||
|
} catch {
|
||||||
|
// 失败已由请求层统一 ElMessage 提示,下拉保持"全部站点"
|
||||||
|
} finally {
|
||||||
|
sitesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
|
site: ALL_SITES,
|
||||||
search: '',
|
search: '',
|
||||||
deviceType: '',
|
deviceType: '',
|
||||||
country: '',
|
country: '',
|
||||||
@@ -119,6 +151,7 @@
|
|||||||
/** 组装请求参数:online/show_bot 用后端哨兵 '1'(类型已锁字面量),空值不发送 */
|
/** 组装请求参数:online/show_bot 用后端哨兵 '1'(类型已锁字面量),空值不发送 */
|
||||||
const buildFilterParams = (): VisitorListParams => {
|
const buildFilterParams = (): VisitorListParams => {
|
||||||
const params: VisitorListParams = {}
|
const params: VisitorListParams = {}
|
||||||
|
if (filters.site !== ALL_SITES) params.site = filters.site
|
||||||
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
|
||||||
@@ -190,6 +223,9 @@
|
|||||||
|
|
||||||
const renderSource = (val: string) => h('span', { class: 'whitespace-nowrap' }, val || '—')
|
const renderSource = (val: string) => h('span', { class: 'whitespace-nowrap' }, val || '—')
|
||||||
|
|
||||||
|
const renderSite = (row: VisitorRow) =>
|
||||||
|
h('span', { class: 'whitespace-nowrap' }, row.site_name || '—')
|
||||||
|
|
||||||
// ---------- 表格 ----------
|
// ---------- 表格 ----------
|
||||||
/** 后端页大小固定 20(PAGE_SIZE):useTable 侧同步 size=20 才能算对总页数;size 不实际发送 */
|
/** 后端页大小固定 20(PAGE_SIZE):useTable 侧同步 size=20 才能算对总页数;size 不实际发送 */
|
||||||
const fetchVisitors = (params: VisitorListParams & { size?: number }) => getVisitors(params)
|
const fetchVisitors = (params: VisitorListParams & { size?: number }) => getVisitors(params)
|
||||||
@@ -226,6 +262,12 @@
|
|||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
formatter: renderLocation
|
formatter: renderLocation
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
prop: 'site_name',
|
||||||
|
label: '所属站点',
|
||||||
|
minWidth: 110,
|
||||||
|
formatter: (row: VisitorRow) => renderSite(row)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
prop: 'device_type',
|
prop: 'device_type',
|
||||||
label: '设备',
|
label: '设备',
|
||||||
@@ -282,6 +324,7 @@
|
|||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
Object.assign(filters, {
|
Object.assign(filters, {
|
||||||
|
site: ALL_SITES,
|
||||||
search: '',
|
search: '',
|
||||||
deviceType: '',
|
deviceType: '',
|
||||||
country: '',
|
country: '',
|
||||||
@@ -291,6 +334,8 @@
|
|||||||
})
|
})
|
||||||
applyFilters()
|
applyFilters()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(loadSites)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""访客列表/详情/跨天时间线/单会话接口(管理台)。
|
"""访客列表/详情/跨天时间线/单会话接口(管理台)。
|
||||||
|
|
||||||
M1 单组织多站点:访客 API 不做 site 过滤参数(后台看全站访客流,站点维度在 Dashboard)。
|
M1 单组织多站点:访客列表支持 ?site= 可选过滤(缺省聚合全部站点),行内随带站点归属
|
||||||
|
(site_id/site_name)——多站点下分辨访客来自哪个站;访客数据本身已按站点隔离。
|
||||||
访客列表查询按 (site,-last_seen_at) 索引逐站扫描或全表 order_by——M1 规模(万级访客)可接受,
|
访客列表查询按 (site,-last_seen_at) 索引逐站扫描或全表 order_by——M1 规模(万级访客)可接受,
|
||||||
量级上来后按站点分片或引入 site 过滤即可(索引已就位)。
|
量级上来后按站点分片即可(索引已就位)。
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from tracker.api.envelope import EnvelopeMixin, ok
|
from tracker.api.envelope import EnvelopeMixin, ok
|
||||||
from tracker.models import Event, VisitSession, Visitor
|
from tracker.models import Event, Site, VisitSession, Visitor
|
||||||
|
|
||||||
PAGE_SIZE = 20
|
PAGE_SIZE = 20
|
||||||
TIMELINE_DEFAULT_DAYS = 30
|
TIMELINE_DEFAULT_DAYS = 30
|
||||||
@@ -36,6 +37,8 @@ def _row(visitor, last_session, online_cutoff):
|
|||||||
"""列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。"""
|
"""列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。"""
|
||||||
return {
|
return {
|
||||||
"visitor_id": visitor.visitor_id,
|
"visitor_id": visitor.visitor_id,
|
||||||
|
# 站点归属:visitor.site 需随行 select_related("site"),否则逐行多一条查询
|
||||||
|
"site_id": visitor.site.site_id, "site_name": visitor.site.name,
|
||||||
"country": visitor.country, "province": visitor.province, "city": visitor.city,
|
"country": visitor.country, "province": visitor.province, "city": visitor.city,
|
||||||
"device_type": visitor.device_type, "os": visitor.os,
|
"device_type": visitor.device_type, "os": visitor.os,
|
||||||
"browser": visitor.browser, "language": visitor.language,
|
"browser": visitor.browser, "language": visitor.language,
|
||||||
@@ -106,7 +109,7 @@ def _page_visitors(qs, request):
|
|||||||
page = 1
|
page = 1
|
||||||
page = max(1, page)
|
page = max(1, page)
|
||||||
|
|
||||||
page_qs = _annotate(qs).order_by("-last_seen_at", "-pk")
|
page_qs = _annotate(qs).select_related("site").order_by("-last_seen_at", "-pk")
|
||||||
visitors = list(page_qs[(page - 1) * PAGE_SIZE: page * PAGE_SIZE])
|
visitors = list(page_qs[(page - 1) * PAGE_SIZE: page * PAGE_SIZE])
|
||||||
latest = {s.pk: s for s in VisitSession.objects.filter(
|
latest = {s.pk: s for s in VisitSession.objects.filter(
|
||||||
pk__in=[v.latest_session_pk for v in visitors if v.latest_session_pk])}
|
pk__in=[v.latest_session_pk for v in visitors if v.latest_session_pk])}
|
||||||
@@ -163,13 +166,21 @@ def _merge_page_stay(events):
|
|||||||
|
|
||||||
class VisitorListView(EnvelopeMixin, APIView):
|
class VisitorListView(EnvelopeMixin, APIView):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
rows, total, page = _page_visitors(Visitor.objects.all(), request)
|
qs = Visitor.objects.all()
|
||||||
|
sid = request.query_params.get("site") # 可选站点过滤,缺省聚合全部(与 dashboard 同款)
|
||||||
|
if sid:
|
||||||
|
site = Site.objects.filter(site_id=sid).first()
|
||||||
|
if not site:
|
||||||
|
return _not_found("site_not_found")
|
||||||
|
qs = qs.filter(site=site)
|
||||||
|
rows, total, page = _page_visitors(qs, request)
|
||||||
return ok({"list": rows, "total": total, "page": page})
|
return ok({"list": rows, "total": total, "page": page})
|
||||||
|
|
||||||
|
|
||||||
class VisitorDetailView(EnvelopeMixin, APIView):
|
class VisitorDetailView(EnvelopeMixin, APIView):
|
||||||
def get(self, request, visitor_id):
|
def get(self, request, visitor_id):
|
||||||
visitor = _annotate(Visitor.objects.filter(visitor_id=visitor_id)) \
|
visitor = _annotate(Visitor.objects.filter(visitor_id=visitor_id)
|
||||||
|
.select_related("site")) \
|
||||||
.order_by("-last_seen_at", "-pk").first()
|
.order_by("-last_seen_at", "-pk").first()
|
||||||
if not visitor:
|
if not visitor:
|
||||||
return _not_found("visitor_not_found")
|
return _not_found("visitor_not_found")
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ def test_list_fields_and_default_bot_excluded(client, member_user):
|
|||||||
assert d["total"] == 2 and d["page"] == 1 and len(d["list"]) == 2
|
assert d["total"] == 2 and d["page"] == 1 and len(d["list"]) == 2
|
||||||
assert [x["visitor_id"] for x in d["list"]] == ["v1", "v2"] # last_seen 倒序
|
assert [x["visitor_id"] for x in d["list"]] == ["v1", "v2"] # last_seen 倒序
|
||||||
row = d["list"][0]
|
row = d["list"][0]
|
||||||
assert set(row) == {"visitor_id", "country", "province", "city", "device_type", "os",
|
assert set(row) == {"visitor_id", "site_id", "site_name", "country", "province", "city",
|
||||||
"browser", "language", "first_seen_at", "last_seen_at", "visit_count",
|
"device_type", "os", "browser", "language", "first_seen_at",
|
||||||
"active_days", "session_count", "total_stay_ms", "first_source",
|
"last_seen_at", "visit_count", "active_days", "session_count",
|
||||||
"last_source", "online"}
|
"total_stay_ms", "first_source", "last_source", "online"}
|
||||||
assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome"
|
assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome"
|
||||||
assert row["session_count"] == 2
|
assert row["session_count"] == 2
|
||||||
assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径
|
assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径
|
||||||
@@ -125,6 +125,34 @@ def test_list_filters_and_search(client, member_user):
|
|||||||
assert ids(d) == ["old-visitor"] # last_seen 落在昨天
|
assert ids(d) == ["old-visitor"] # last_seen 落在昨天
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_site_fields_and_site_filter(client, member_user):
|
||||||
|
_auth(client, member_user)
|
||||||
|
site_a = Site.objects.create(name="站点A", domains=["a.com"])
|
||||||
|
site_b = Site.objects.create(name="站点B", domains=["b.com"])
|
||||||
|
_visitor(site_a, "va", last_seen=timezone.now())
|
||||||
|
_visitor(site_b, "vb", last_seen=timezone.now() - timedelta(hours=1))
|
||||||
|
|
||||||
|
d = client.get("/api/admin/visitors").json()["data"] # 缺省聚合全部站点
|
||||||
|
assert d["total"] == 2 and len(d["list"]) == 2
|
||||||
|
by_id = {x["visitor_id"]: x for x in d["list"]}
|
||||||
|
assert by_id["va"]["site_id"] == site_a.site_id and by_id["va"]["site_name"] == "站点A"
|
||||||
|
assert by_id["vb"]["site_id"] == site_b.site_id and by_id["vb"]["site_name"] == "站点B"
|
||||||
|
|
||||||
|
d = client.get("/api/admin/visitors", {"site": site_b.site_id}).json()["data"]
|
||||||
|
assert d["total"] == 1 and [x["visitor_id"] for x in d["list"]] == ["vb"] # 只剩本站访客
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_site_not_found(client, member_user):
|
||||||
|
_auth(client, member_user)
|
||||||
|
site = Site.objects.create(name="s", domains=["a.com"])
|
||||||
|
_visitor(site, "v1", last_seen=timezone.now())
|
||||||
|
|
||||||
|
r = client.get("/api/admin/visitors", {"site": "site_nope"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
body = r.json()
|
||||||
|
assert body["code"] == 404 and body["data"] is None and body["message"] == "site_not_found"
|
||||||
|
|
||||||
|
|
||||||
def test_list_pagination_and_order(client, member_user):
|
def test_list_pagination_and_order(client, member_user):
|
||||||
_auth(client, member_user)
|
_auth(client, member_user)
|
||||||
site = Site.objects.create(name="s", domains=["a.com"])
|
site = Site.objects.create(name="s", domains=["a.com"])
|
||||||
@@ -153,6 +181,7 @@ def test_detail_shape(client, member_user):
|
|||||||
r = client.get("/api/admin/visitors/v1")
|
r = client.get("/api/admin/visitors/v1")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
d = r.json()["data"]
|
d = r.json()["data"]
|
||||||
|
assert d["site_id"] == site.site_id and d["site_name"] == "s" # 详情同样输出站点归属
|
||||||
assert d["screen"] == "1920x1080" and d["timezone_name"] == "Asia/Shanghai"
|
assert d["screen"] == "1920x1080" and d["timezone_name"] == "Asia/Shanghai"
|
||||||
assert d["last_ip"] == "1.2.3.4" and d["first_utm"] == {"utm_source": "google"}
|
assert d["last_ip"] == "1.2.3.4" and d["first_utm"] == {"utm_source": "google"}
|
||||||
assert d["first_landing_path"] == "/landing" and d["first_referrer_domain"] == "google.com"
|
assert d["first_landing_path"] == "/landing" and d["first_referrer_domain"] == "google.com"
|
||||||
|
|||||||
Reference in New Issue
Block a user