diff --git a/admin/src/views/tracker/dashboard/index.vue b/admin/src/views/tracker/dashboard/index.vue index 0a6ee22..625e4a5 100644 --- a/admin/src/views/tracker/dashboard/index.vue +++ b/admin/src/views/tracker/dashboard/index.vue @@ -142,17 +142,21 @@ } } + // 请求令牌:快速切换站点时丢弃过期响应,避免旧数据覆盖新数据/旧 finally 提前清 loading + let reqSeq = 0 const loadOverview = async () => { + const id = ++reqSeq overviewLoading.value = true try { - overview.value = await getOverview( + const resp = await getOverview( selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value ) + if (id === reqSeq) overview.value = resp } catch { // 失败已由请求层统一 ElMessage 提示;overview 置空让图表/表格走空态 - overview.value = null + if (id === reqSeq) overview.value = null } finally { - overviewLoading.value = false + if (id === reqSeq) overviewLoading.value = false } } diff --git a/admin/src/views/tracker/site/index.vue b/admin/src/views/tracker/site/index.vue index fb6e62e..63d4662 100644 --- a/admin/src/views/tracker/site/index.vue +++ b/admin/src/views/tracker/site/index.vue @@ -91,6 +91,9 @@ return s.split('/')[0].split(':')[0] } + /** 轻量主机名校验:点分级,每级字母/数字/连字符且不以连字符开头结尾(IPv4/单级内网名天然匹配) */ + const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/ + /** textarea 按行解析 → 归一 → 去空去重 */ const parseDomains = (text: string): string[] => { const out: string[] = [] @@ -246,9 +249,14 @@ validator: (_rule, value: string, callback) => { if (value && !parseDomains(value).length) { callback(new Error('未解析到有效域名,请检查格式')) - } else { - callback() + return } + const invalid = parseDomains(value).filter((d) => !HOSTNAME_RE.test(d)) + if (invalid.length) { + callback(new Error(`以下内容不是有效主机名:${invalid.join('、')}`)) + return + } + callback() }, trigger: 'blur' } @@ -264,6 +272,10 @@ .map((l) => l.trim()) .filter(Boolean) const normalized = parseDomains(text) + const invalid = normalized.filter((d) => !HOSTNAME_RE.test(d)) + if (invalid.length) { + return `以下内容不是有效主机名:${invalid.join('、')}(仅限字母/数字/连字符/点)` + } const changed = normalized.join('\n') !== rawLines.join('\n') || rawLines.length !== normalized.length if (!changed) return '' diff --git a/server/tracker/api/sites.py b/server/tracker/api/sites.py index 4c8d54c..49f1d2b 100644 --- a/server/tracker/api/sites.py +++ b/server/tracker/api/sites.py @@ -1,4 +1,5 @@ from django.shortcuts import get_object_or_404 +from django.utils import timezone from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAdminUser @@ -33,9 +34,12 @@ def _to_bool(v) -> bool: def _serialize(site: Site) -> dict: + # USE_TZ=True 下 isoformat() 是 UTC("+00:00"),前端直接切串显示会与 + # Dashboard 的上海自然日口径同屏矛盾——统一按上海时区序列化。 return {"id": site.pk, "name": site.name, "site_id": site.site_id, "domains": site.domains, "allow_no_origin": site.allow_no_origin, - "is_active": site.is_active, "created_at": site.created_at.isoformat()} + "is_active": site.is_active, + "created_at": timezone.localtime(site.created_at).isoformat()} class SiteListView(EnvelopeMixin, APIView): diff --git a/server/tracker/api/visitors.py b/server/tracker/api/visitors.py index da0ff21..28f390a 100644 --- a/server/tracker/api/visitors.py +++ b/server/tracker/api/visitors.py @@ -26,6 +26,12 @@ def _source(referrer_domain, utm): return referrer_domain or (utm or {}).get("utm_source") or "直接访问" +def _iso(dt): + """输出给前端的时间统一按当前时区(上海)序列化:USE_TZ=True 下 isoformat() 是 UTC + ("+00:00"),前端切串直显会与 Dashboard 的上海自然日口径同屏矛盾(差 8 小时)。""" + return timezone.localtime(dt).isoformat() if dt else None + + def _row(visitor, last_session, online_cutoff): """列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。""" return { @@ -33,8 +39,8 @@ def _row(visitor, last_session, online_cutoff): "country": visitor.country, "province": visitor.province, "city": visitor.city, "device_type": visitor.device_type, "os": visitor.os, "browser": visitor.browser, "language": visitor.language, - "first_seen_at": visitor.first_seen_at.isoformat(), - "last_seen_at": visitor.last_seen_at.isoformat(), + "first_seen_at": _iso(visitor.first_seen_at), + "last_seen_at": _iso(visitor.last_seen_at), "visit_count": visitor.visit_count, "active_days": visitor.active_days, "session_count": visitor.session_count, "total_stay_ms": visitor.total_stay_ms or 0, # 无会话时 Sum 为 None → 0 @@ -48,7 +54,7 @@ def _row(visitor, last_session, online_cutoff): def _session_summary(s): # duration_ms 仅内部字段不展示——前端时长一律用 stay_ms(有效停留口径) return {"session_id": s.session_id, - "started_at": s.started_at.isoformat(), "ended_at": s.ended_at.isoformat(), + "started_at": _iso(s.started_at), "ended_at": _iso(s.ended_at), "referrer_domain": s.referrer_domain, "utm": s.utm, "landing_path": s.landing_path, "event_count": s.event_count, "stay_ms": s.stay_ms} @@ -145,12 +151,12 @@ def _merge_page_stay(events): it["active_ms"] += ms break else: - items.append({"time": e.occurred_at.isoformat(), "event_name": e.event_name, + items.append({"time": _iso(e.occurred_at), "event_name": e.event_name, "path": e.path, "title": e.title, "active_ms": ms}) else: # page_view 初始 active_ms=0;其余(sdk_loaded/自定义事件)无停留概念 → null active = 0 if e.event_name == "page_view" else None - items.append({"time": e.occurred_at.isoformat(), "event_name": e.event_name, + items.append({"time": _iso(e.occurred_at), "event_name": e.event_name, "path": e.path, "title": e.title, "active_ms": active}) return items @@ -211,7 +217,7 @@ class VisitorTimelineView(EnvelopeMixin, APIView): return ok({"days": [{"date": d.isoformat(), "sessions": [{"session_id": b["session"].session_id if b["session"] else None, - "started_at": b["session"].started_at.isoformat() + "started_at": _iso(b["session"].started_at) if b["session"] else None, "events": _merge_page_stay(b["events"])} for b in groups[d].values()]}