新增 /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/爬虫过滤。
446 lines
12 KiB
HTML
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" .}}
|