Files
tamakyi 4e574ac64a feat(insights): 箱主数据面板——访问量、提问趋势、处理漏斗
新增 /user/insights,让箱主第一次能看到「有多少人来过我的箱、提问处理得怎么样」。

数据来源:
- 新表 box_views:一行 = 一访客×一天×一箱,views 累加,唯一索引
  (user_id, viewed_on, visitor)。刻意不内嵌 dbutil.Model —— 软删除 + 唯一索引
  会让被删行永久占槽,且删过的槽位再也插不进去。
- 访客标识 = HMAC(日盐, IP) 前 32 hex:同日可去重(UV 准)、跨日不可关联(隐私)。
  代价是近 7/30 天 UV = 各日 UV 之和,页面上已写明口径。

写入是异步的:buffered channel 512 + 2s 批量 flush,队列满即丢(统计尽力而为,
不该因为记一笔访问就拖慢页面)。过滤箱主本人、空 UA、爬虫 UA、预取请求。
密钥复用 server.xsrf_key;每日清理 400 天前的明细。

页面(纯 CSS 画图,不引图表库):
- 今日/近 7 天/近 30 天 KPI + 上周对比
- 30 天 PV/UV 双柱趋势、24 小时时段分布
- 提问处理漏斗 + 回答率进度条 + 自动结论

聚合层 internal/db/box_analytics.go 复用 analyticsDialectFor 的时区处理,
与全站 stats 分开(那个不含单箱维度、按自然日缓存)。

测试:box_views 4 项(含「同批同键先内存合并」——PG 的 ON CONFLICT 不允许
同条 INSERT 二次命中同一行)、insights 的 pct/窗口切分/visitorID/爬虫过滤。
2026-10-29 18:25:53 +08:00

446 lines
12 KiB
HTML

{{template "base/header" .}}
{{ $ins := .Insights }}{{ $lang := .LangCode }}
<style>
/* 箱主数据面板:与 /stats 一样只用令牌 + CSS 画图,不引任何图表库。
数字用 tabular-nums 对齐,暗色主题靠 --tb-* 令牌自动跟随。 */
.tb-ins {
font-variant-numeric: tabular-nums;
}
.tb-ins-head {
padding: 4px 2px 14px;
}
.tb-ins-head h2 {
margin: 0;
font-size: var(--tb-fs-xl);
font-weight: 600;
color: var(--tb-text);
}
.tb-ins-head p {
margin: 8px 0 0;
font-size: var(--tb-fs-md);
line-height: 1.7;
color: var(--tb-text-muted);
}
.tb-ins-back {
display: inline-block;
margin-bottom: 10px;
font-size: var(--tb-fs-sm);
color: var(--tb-text-muted);
text-decoration: none;
}
.tb-ins-back:hover { color: var(--tb-primary); }
.tb-card {
background: var(--tb-surface);
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius);
padding: 18px 18px 20px;
margin-bottom: 12px;
}
.tb-card-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
margin: 0 0 4px;
font-size: var(--tb-fs-lg);
font-weight: 600;
color: var(--tb-text);
}
.tb-card-note {
margin: 0 0 16px;
font-size: var(--tb-fs-xs);
line-height: 1.6;
color: var(--tb-text-muted);
}
/* 结论列表 */
.tb-ins-list {
margin: 0;
padding: 0;
list-style: none;
}
.tb-ins-list li {
position: relative;
padding: 6px 0 6px 18px;
font-size: var(--tb-fs-md);
line-height: 1.7;
color: var(--tb-text-secondary);
}
.tb-ins-list li::before {
content: "";
position: absolute;
left: 4px;
top: 14px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--tb-primary);
opacity: .55;
}
/* 三组窗口 */
.tb-wins {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
margin-bottom: 12px;
}
@media (min-width: 620px) {
.tb-wins { grid-template-columns: repeat(3, 1fr); }
}
.tb-win {
padding: 14px 14px 16px;
background: var(--tb-surface);
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
}
.tb-win-title {
margin: 0 0 10px;
font-size: var(--tb-fs-xs);
letter-spacing: .04em;
color: var(--tb-text-muted);
}
.tb-win-main {
display: flex;
align-items: baseline;
gap: 6px;
}
.tb-win-num {
font-size: 1.75rem;
line-height: 1.1;
font-weight: 700;
color: var(--tb-text);
}
.tb-win-unit {
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-win-sub {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
margin-top: 10px;
font-size: var(--tb-fs-sm);
color: var(--tb-text-secondary);
}
.tb-win-sub b {
font-weight: 600;
color: var(--tb-text);
}
.tb-delta {
font-size: var(--tb-fs-xs);
padding: 1px 6px;
border-radius: 999px;
}
.tb-delta.up { background: var(--tb-primary-weak); color: var(--tb-primary); }
.tb-delta.down { background: var(--tb-surface-2); color: var(--tb-text-muted); }
/* 柱状趋势:每列两个绝对定位的色块,UV 在底部、PV 全高叠在后面 */
.tb-trend {
display: flex;
align-items: flex-end;
gap: 2px;
height: 168px;
margin-top: 4px;
}
.tb-trend-col {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 100%;
}
.tb-trend-pv,
.tb-trend-uv {
position: absolute;
left: 0;
right: 0;
bottom: 0;
border-radius: 2px 2px 0 0;
}
.tb-trend-pv {
background: var(--tb-primary);
opacity: .26;
min-height: 2px;
}
.tb-trend-uv {
background: var(--tb-primary);
opacity: .85;
min-height: 1px;
}
.tb-trend-col.is-today .tb-trend-uv {
outline: 1px solid var(--tb-primary);
}
.tb-trend-col.is-empty .tb-trend-pv { min-height: 2px; opacity: .10; }
.tb-trend-col.is-empty .tb-trend-uv { display: none; }
.tb-axis {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin-top: 10px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-legend i {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 5px;
border-radius: 2px;
vertical-align: -1px;
}
/* 时段分布 */
.tb-hours {
display: flex;
align-items: flex-end;
gap: 3px;
height: 108px;
margin-top: 4px;
}
.tb-hour {
flex: 1 1 0;
min-width: 0;
height: 100%;
display: flex;
align-items: flex-end;
}
.tb-hour-bar {
width: 100%;
border-radius: 2px 2px 0 0;
background: var(--tb-primary);
opacity: .55;
min-height: 2px;
}
.tb-hour.is-peak .tb-hour-bar { opacity: 1; }
.tb-hour-axis {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
/* 漏斗 */
.tb-funnel {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 14px;
}
@media (min-width: 560px) {
.tb-funnel { grid-template-columns: repeat(4, 1fr); }
}
.tb-funnel-cell {
padding: 12px 12px 14px;
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
background: var(--tb-surface-2);
}
.tb-funnel-num {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: var(--tb-text);
}
.tb-funnel-lbl {
display: block;
margin-top: 4px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-ratio {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
}
.tb-ratio-track {
flex: 1;
height: 8px;
border-radius: 999px;
background: var(--tb-surface-2);
overflow: hidden;
}
.tb-ratio-fill {
height: 100%;
border-radius: 999px;
background: var(--tb-primary);
}
.tb-ratio-val {
font-size: var(--tb-fs-sm);
font-weight: 600;
color: var(--tb-text);
min-width: 3.5em;
text-align: right;
}
.tb-ratio-lbl {
margin: 12px 0 0;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-empty {
padding: 18px 0;
text-align: center;
font-size: var(--tb-fs-md);
color: var(--tb-text-muted);
}
</style>
<div class="tb-ins uk-container uk-container-small">
<a class="tb-ins-back" href="/user/profile">← {{t $lang "insights.back"}}</a>
<div class="tb-ins-head">
<h2>{{t $lang "insights.heading"}}</h2>
<p>{{t $lang "insights.lead"}}</p>
</div>
{{ if $ins.Conclusions }}
<div class="tb-card">
<h3 class="tb-card-title">{{t $lang "insights.summary"}}</h3>
<ul class="tb-ins-list">
{{ range $ins.Conclusions }}<li>{{ . }}</li>{{ end }}
</ul>
</div>
{{ end }}
{{ if not $ins.HasViews }}
<div class="tb-card">
<div class="tb-empty">{{t $lang "insights.noViews"}}</div>
</div>
{{ end }}
<!-- 三个窗口 -->
<div class="tb-wins">
<div class="tb-win">
<p class="tb-win-title">{{t $lang "insights.today"}}</p>
<div class="tb-win-main">
<span class="tb-win-num">{{ $ins.Today.PV }}</span>
<span class="tb-win-unit">{{t $lang "insights.pv"}}</span>
</div>
<div class="tb-win-sub">
<span>{{t $lang "insights.uv"}} <b>{{ $ins.Today.UV }}</b></span>
<span>{{t $lang "insights.newQuestions"}} <b>{{ $ins.Today.Questions }}</b></span>
</div>
</div>
<div class="tb-win">
<p class="tb-win-title">{{t $lang "insights.week"}}</p>
<div class="tb-win-main">
<span class="tb-win-num">{{ $ins.Week.PV }}</span>
<span class="tb-win-unit">{{t $lang "insights.pv"}}</span>
{{ if $ins.Week.HasDelta }}
{{ if ge $ins.Week.Delta 0.0 }}
<span class="tb-delta up">+{{ printf "%.0f" $ins.Week.Delta }}%</span>
{{ else }}
<span class="tb-delta down">{{ printf "%.0f" $ins.Week.Delta }}%</span>
{{ end }}
{{ end }}
</div>
<div class="tb-win-sub">
<span>{{t $lang "insights.uv"}} <b>{{ $ins.Week.UV }}</b></span>
<span>{{t $lang "insights.newQuestions"}} <b>{{ $ins.Week.Questions }}</b></span>
</div>
</div>
<div class="tb-win">
<p class="tb-win-title">{{t $lang "insights.month"}}</p>
<div class="tb-win-main">
<span class="tb-win-num">{{ $ins.Month.PV }}</span>
<span class="tb-win-unit">{{t $lang "insights.pv"}}</span>
</div>
<div class="tb-win-sub">
<span>{{t $lang "insights.uv"}} <b>{{ $ins.Month.UV }}</b></span>
<span>{{t $lang "insights.newQuestions"}} <b>{{ $ins.Month.Questions }}</b></span>
</div>
</div>
</div>
<!-- 30 天趋势 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $lang "insights.trendTitle"}}</h3>
<p class="tb-card-note">{{t $lang "insights.trendNote"}}</p>
<div class="tb-trend" role="img" aria-label="{{t $lang "insights.trendTitle"}}">
{{ range $ins.Days }}
<div class="tb-trend-col{{ if .IsToday }} is-today{{ end }}{{ if .IsEmpty }} is-empty{{ end }}"
title="{{ .Label }} · {{ .PV }} / {{ .UV }} / {{ .Questions }}">
<div class="tb-trend-pv" style="height: {{ .PVHeight }}%"></div>
<div class="tb-trend-uv" style="height: {{ .UVHeight }}%"></div>
</div>
{{ end }}
</div>
<div class="tb-axis">
{{ range $i, $d := $ins.Days }}{{ if or (eq $i 0) (eq $i 9) (eq $i 19) (eq $i 29) }}<span>{{ $d.Label }}</span>{{ end }}{{ end }}
</div>
<div class="tb-legend">
<span><i style="background: var(--tb-primary); opacity: .26"></i>{{t $lang "insights.legendPV"}}</span>
<span><i style="background: var(--tb-primary)"></i>{{t $lang "insights.legendUV"}}</span>
<span>{{t $lang "insights.legendHover"}}</span>
</div>
</div>
<!-- 时段分布 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $lang "insights.hourTitle"}}</h3>
<p class="tb-card-note">{{t $lang "insights.hourNote"}}</p>
<div class="tb-hours">
{{ range $ins.Hours }}
<div class="tb-hour{{ if and (eq .Hour $ins.PeakHour) (gt $ins.PeakHourN 0) }} is-peak{{ end }}"
title="{{ printf "%02d:00" .Hour }} · {{ .N }}">
<div class="tb-hour-bar" style="height: {{ .Height }}%"></div>
</div>
{{ end }}
</div>
<div class="tb-hour-axis">
<span>00</span><span>06</span><span>12</span><span>18</span><span>23</span>
</div>
</div>
<!-- 处理漏斗 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $lang "insights.funnelTitle"}}</h3>
<p class="tb-card-note">{{t $lang "insights.funnelNote"}}</p>
<div class="tb-funnel">
<div class="tb-funnel-cell">
<span class="tb-funnel-num">{{ $ins.Funnel.Total }}</span>
<span class="tb-funnel-lbl">{{t $lang "insights.funnelTotal"}}</span>
</div>
<div class="tb-funnel-cell">
<span class="tb-funnel-num">{{ $ins.Funnel.Answered }}</span>
<span class="tb-funnel-lbl">{{t $lang "insights.funnelAnswered"}}</span>
</div>
<div class="tb-funnel-cell">
<span class="tb-funnel-num">{{ $ins.Funnel.ReadOnly }}</span>
<span class="tb-funnel-lbl">{{t $lang "insights.funnelReadOnly"}}</span>
</div>
<div class="tb-funnel-cell">
<span class="tb-funnel-num">{{ $ins.Funnel.Unread }}</span>
<span class="tb-funnel-lbl">{{t $lang "insights.funnelUnread"}}</span>
</div>
</div>
<div class="tb-ratio">
<span class="tb-ratio-val">{{ $ins.AnswerRateText }}%</span>
<div class="tb-ratio-track">
<div class="tb-ratio-fill" style="width: {{ $ins.AnswerRate }}%"></div>
</div>
</div>
<p class="tb-ratio-lbl">{{t $lang "insights.answerRate"}}</p>
</div>
<div class="tb-card">
<h3 class="tb-card-title">{{t $lang "insights.privacyTitle"}}</h3>
<p class="tb-card-note" style="margin-bottom:0">{{t $lang "insights.privacyNote"}}</p>
</div>
</div>
{{template "base/footer" .}}