fix: 总览筛选竞态守卫 + 时间戳统一上海时间序列化
- dashboard loadOverview 加请求令牌:快速切换站点时丢弃过期响应, 防旧数据覆盖与旧 finally 提前清 loading - 后端序列化统一 timezone.localtime:sites.created_at 与 visitors 的 first/last_seen_at、会话 started/ended_at、时间线事件 time—— USE_TZ 下 isoformat() 是 UTC,与上海自然日口径同屏矛盾(差 8 小时) - 站点弹窗域名加轻量主机名正则校验与实时提示(拦含空格等垃圾输入) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -142,17 +142,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 请求令牌:快速切换站点时丢弃过期响应,避免旧数据覆盖新数据/旧 finally 提前清 loading
|
||||||
|
let reqSeq = 0
|
||||||
const loadOverview = async () => {
|
const loadOverview = async () => {
|
||||||
|
const id = ++reqSeq
|
||||||
overviewLoading.value = true
|
overviewLoading.value = true
|
||||||
try {
|
try {
|
||||||
overview.value = await getOverview(
|
const resp = await getOverview(
|
||||||
selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value
|
selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value
|
||||||
)
|
)
|
||||||
|
if (id === reqSeq) overview.value = resp
|
||||||
} catch {
|
} catch {
|
||||||
// 失败已由请求层统一 ElMessage 提示;overview 置空让图表/表格走空态
|
// 失败已由请求层统一 ElMessage 提示;overview 置空让图表/表格走空态
|
||||||
overview.value = null
|
if (id === reqSeq) overview.value = null
|
||||||
} finally {
|
} finally {
|
||||||
overviewLoading.value = false
|
if (id === reqSeq) overviewLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,9 @@
|
|||||||
return s.split('/')[0].split(':')[0]
|
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 按行解析 → 归一 → 去空去重 */
|
/** textarea 按行解析 → 归一 → 去空去重 */
|
||||||
const parseDomains = (text: string): string[] => {
|
const parseDomains = (text: string): string[] => {
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
@@ -246,9 +249,14 @@
|
|||||||
validator: (_rule, value: string, callback) => {
|
validator: (_rule, value: string, callback) => {
|
||||||
if (value && !parseDomains(value).length) {
|
if (value && !parseDomains(value).length) {
|
||||||
callback(new Error('未解析到有效域名,请检查格式'))
|
callback(new Error('未解析到有效域名,请检查格式'))
|
||||||
} else {
|
return
|
||||||
callback()
|
|
||||||
}
|
}
|
||||||
|
const invalid = parseDomains(value).filter((d) => !HOSTNAME_RE.test(d))
|
||||||
|
if (invalid.length) {
|
||||||
|
callback(new Error(`以下内容不是有效主机名:${invalid.join('、')}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
},
|
},
|
||||||
trigger: 'blur'
|
trigger: 'blur'
|
||||||
}
|
}
|
||||||
@@ -264,6 +272,10 @@
|
|||||||
.map((l) => l.trim())
|
.map((l) => l.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
const normalized = parseDomains(text)
|
const normalized = parseDomains(text)
|
||||||
|
const invalid = normalized.filter((d) => !HOSTNAME_RE.test(d))
|
||||||
|
if (invalid.length) {
|
||||||
|
return `以下内容不是有效主机名:${invalid.join('、')}(仅限字母/数字/连字符/点)`
|
||||||
|
}
|
||||||
const changed =
|
const changed =
|
||||||
normalized.join('\n') !== rawLines.join('\n') || rawLines.length !== normalized.length
|
normalized.join('\n') !== rawLines.join('\n') || rawLines.length !== normalized.length
|
||||||
if (!changed) return ''
|
if (!changed) return ''
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import IsAdminUser
|
from rest_framework.permissions import IsAdminUser
|
||||||
@@ -33,9 +34,12 @@ def _to_bool(v) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _serialize(site: Site) -> dict:
|
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,
|
return {"id": site.pk, "name": site.name, "site_id": site.site_id,
|
||||||
"domains": site.domains, "allow_no_origin": site.allow_no_origin,
|
"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):
|
class SiteListView(EnvelopeMixin, APIView):
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ def _source(referrer_domain, utm):
|
|||||||
return referrer_domain or (utm or {}).get("utm_source") or "直接访问"
|
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):
|
def _row(visitor, last_session, online_cutoff):
|
||||||
"""列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。"""
|
"""列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。"""
|
||||||
return {
|
return {
|
||||||
@@ -33,8 +39,8 @@ def _row(visitor, last_session, online_cutoff):
|
|||||||
"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,
|
||||||
"first_seen_at": visitor.first_seen_at.isoformat(),
|
"first_seen_at": _iso(visitor.first_seen_at),
|
||||||
"last_seen_at": visitor.last_seen_at.isoformat(),
|
"last_seen_at": _iso(visitor.last_seen_at),
|
||||||
"visit_count": visitor.visit_count, "active_days": visitor.active_days,
|
"visit_count": visitor.visit_count, "active_days": visitor.active_days,
|
||||||
"session_count": visitor.session_count,
|
"session_count": visitor.session_count,
|
||||||
"total_stay_ms": visitor.total_stay_ms or 0, # 无会话时 Sum 为 None → 0
|
"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):
|
def _session_summary(s):
|
||||||
# duration_ms 仅内部字段不展示——前端时长一律用 stay_ms(有效停留口径)
|
# duration_ms 仅内部字段不展示——前端时长一律用 stay_ms(有效停留口径)
|
||||||
return {"session_id": s.session_id,
|
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,
|
"referrer_domain": s.referrer_domain, "utm": s.utm,
|
||||||
"landing_path": s.landing_path, "event_count": s.event_count,
|
"landing_path": s.landing_path, "event_count": s.event_count,
|
||||||
"stay_ms": s.stay_ms}
|
"stay_ms": s.stay_ms}
|
||||||
@@ -145,12 +151,12 @@ def _merge_page_stay(events):
|
|||||||
it["active_ms"] += ms
|
it["active_ms"] += ms
|
||||||
break
|
break
|
||||||
else:
|
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})
|
"path": e.path, "title": e.title, "active_ms": ms})
|
||||||
else:
|
else:
|
||||||
# page_view 初始 active_ms=0;其余(sdk_loaded/自定义事件)无停留概念 → null
|
# page_view 初始 active_ms=0;其余(sdk_loaded/自定义事件)无停留概念 → null
|
||||||
active = 0 if e.event_name == "page_view" else None
|
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})
|
"path": e.path, "title": e.title, "active_ms": active})
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@@ -211,7 +217,7 @@ class VisitorTimelineView(EnvelopeMixin, APIView):
|
|||||||
|
|
||||||
return ok({"days": [{"date": d.isoformat(),
|
return ok({"days": [{"date": d.isoformat(),
|
||||||
"sessions": [{"session_id": b["session"].session_id if b["session"] else None,
|
"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,
|
if b["session"] else None,
|
||||||
"events": _merge_page_stay(b["events"])}
|
"events": _merge_page_stay(b["events"])}
|
||||||
for b in groups[d].values()]}
|
for b in groups[d].values()]}
|
||||||
|
|||||||
Reference in New Issue
Block a user