以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。
新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
(一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
每次运行精修到不同 IP,配额花在长尾上。
报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。
存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)
配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。
顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
392 lines
16 KiB
Go
392 lines
16 KiB
Go
// Copyright 2026 tamakyi. All rights reserved.
|
||
// Use of this source code is governed by a MIT-style
|
||
// license that can be found in the LICENSE file.
|
||
|
||
package report
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"fmt"
|
||
"html"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// HTMLOptions 渲染选项。
|
||
type HTMLOptions struct {
|
||
// TencentMapKey 腾讯位置服务 key。留空则报告里只有离线 SVG 地图,
|
||
// 并附一句「需要可交互底图请配置 ip_geo_tencent_key」。
|
||
TencentMapKey string
|
||
}
|
||
|
||
// tencentKeyPlaceholder 未配置 key 时写进页面的占位符。
|
||
//
|
||
// 按地图合规要求,代码里绝不预置任何可用的 key;占位符要让拿到报告的人
|
||
// 一眼看出「这里需要你自己申请」,而不是以为地图坏了。
|
||
const tencentKeyPlaceholder = "请前往 lbs.qq.com 申请 key 后填入 [security] ip_geo_tencent_key"
|
||
|
||
// RenderHTML 生成完整的自包含报告页。
|
||
//
|
||
// 自包含的含义:不依赖 site 的 CSS、不依赖任何外链图片(腾讯地图那一块是
|
||
// 可选的在线增强,其余部分断网也能看)。
|
||
func RenderHTML(sum *Summary, opts HTMLOptions) string {
|
||
var b strings.Builder
|
||
|
||
title := sum.Period.Title()
|
||
b.WriteString(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">`)
|
||
b.WriteString(`<meta name="viewport" content="width=device-width,initial-scale=1">`)
|
||
fmt.Fprintf(&b, `<title>%s</title>`, html.EscapeString(title))
|
||
b.WriteString(pageCSS)
|
||
b.WriteString(`</head><body><div class="wrap">`)
|
||
|
||
// 头部
|
||
fmt.Fprintf(&b, `<header><h1>%s</h1><p class="sub">统计窗口 <b>%s</b> · 生成于 %s</p></header>`,
|
||
html.EscapeString(title), html.EscapeString(sum.Period.Label()),
|
||
sum.GeneratedAt.Format("2006-01-02 15:04"))
|
||
|
||
if sum.Empty {
|
||
b.WriteString(`<div class="empty">本期没有捕获到任何蜜罐命中 —— 这本身是个好消息,但也请确认蜜罐仍然开启(<code>TamaBox honeypot paths</code>)。</div>`)
|
||
b.WriteString(`</div></body></html>`)
|
||
return b.String()
|
||
}
|
||
|
||
// KPI
|
||
b.WriteString(`<section class="kpis">`)
|
||
writeKPI(&b, "蜜罐命中", fmt.Sprint(sum.TotalHits), trendNote(sum.TotalHits, sum.PrevHits))
|
||
writeKPI(&b, "独立来源", fmt.Sprint(sum.UniqueSources), fmt.Sprintf("覆盖 %d 个国家和地区", sum.UniqueCountry))
|
||
writeKPI(&b, "已拦截", fmt.Sprint(sum.BannedHits), pct(sum.BannedHits, sum.TotalHits))
|
||
writeKPI(&b, "真实破解尝试", fmt.Sprint(sum.CredHits), "提交了账号密码")
|
||
writeKPI(&b, "云主机来源", fmt.Sprint(sum.CloudSources), fmt.Sprintf("占比 %s", pctInt(int64(sum.CloudSources), int64(sum.UniqueSources))))
|
||
b.WriteString(`</section>`)
|
||
|
||
// 地图
|
||
b.WriteString(`<section><h2>来源分布</h2>`)
|
||
b.WriteString(`<p class="note">气泡面积与命中次数成正比。地图上的点是 IP 库给出的城市级定位,精度到城市,不代表对方的真实街道位置。</p>`)
|
||
b.WriteString(`<div class="map-frame">`)
|
||
b.WriteString(RenderSVG(sum))
|
||
b.WriteString(`</div>`)
|
||
b.WriteString(`<p class="note">⚠️ 本图只画经纬网与气泡,<b>不含任何国界</b>。国界、台湾与南海诸岛的正确绘制须符合国家标准,自绘底图容易出错,因此这里不做底图;需要真实底图请看下方的腾讯地图版本。</p>`)
|
||
b.WriteString(`</section>`)
|
||
|
||
// 腾讯可交互地图
|
||
b.WriteString(`<section><h2>可交互地图</h2>`)
|
||
b.WriteString(renderTencentMap(sum, opts.TencentMapKey))
|
||
b.WriteString(`</section>`)
|
||
|
||
// 国家排行
|
||
b.WriteString(`<section><h2>国家 / 地区排行</h2>`)
|
||
b.WriteString(`<table><thead><tr><th>#</th><th>国家 / 地区</th><th class="num">命中</th><th class="num">占比</th><th class="num">独立来源</th></tr></thead><tbody>`)
|
||
for i, c := range sum.Countries {
|
||
if i >= 20 {
|
||
break
|
||
}
|
||
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s <span class="muted">%s</span></td><td class="num">%d</td><td class="num">%s</td><td class="num">%d</td></tr>`,
|
||
i+1, html.EscapeString(c.Name), html.EscapeString(c.Code), c.Hits, pctFloat(c.Share), c.Sources)
|
||
}
|
||
b.WriteString(`</tbody></table></section>`)
|
||
|
||
// 城市排行
|
||
if len(sum.Cities) > 0 {
|
||
b.WriteString(`<section><h2>城市排行(前 20)</h2>`)
|
||
b.WriteString(`<table><thead><tr><th>#</th><th>城市</th><th>国家</th><th class="num">命中</th><th class="num">独立来源</th></tr></thead><tbody>`)
|
||
for i, c := range sum.Cities {
|
||
if i >= 20 {
|
||
break
|
||
}
|
||
city := c.City
|
||
if city == "" {
|
||
city = c.Region
|
||
}
|
||
if city == "" {
|
||
city = "(未知城市)"
|
||
}
|
||
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s</td><td>%s</td><td class="num">%d</td><td class="num">%d</td></tr>`,
|
||
i+1, html.EscapeString(city), html.EscapeString(c.Country), c.Hits, c.Sources)
|
||
}
|
||
b.WriteString(`</tbody></table></section>`)
|
||
}
|
||
|
||
// 来源明细
|
||
b.WriteString(`<section><h2>来源明细(前 50)</h2>`)
|
||
b.WriteString(`<table><thead><tr><th>IP</th><th>位置</th><th>运营商 / ASN</th><th>网络归属</th><th class="num">命中</th><th class="num">破解</th><th class="num">拦截</th></tr></thead><tbody>`)
|
||
for i, s := range sum.Sources {
|
||
if i >= 50 {
|
||
break
|
||
}
|
||
provider := s.Provider
|
||
if provider == "" {
|
||
provider = "—"
|
||
}
|
||
isp := s.ISP
|
||
if s.ASN != "" {
|
||
if isp != "" {
|
||
isp += " · "
|
||
}
|
||
isp += s.ASN
|
||
}
|
||
if isp == "" {
|
||
isp = "—"
|
||
}
|
||
fmt.Fprintf(&b, `<tr><td class="mono">%s</td><td>%s</td><td class="small">%s</td><td class="small">%s</td><td class="num">%d</td><td class="num">%d</td><td class="num">%d</td></tr>`,
|
||
html.EscapeString(s.IP), html.EscapeString(s.Location()),
|
||
html.EscapeString(isp), html.EscapeString(provider),
|
||
s.Hits, s.Creds, s.Banned)
|
||
}
|
||
b.WriteString(`</tbody></table>`)
|
||
if len(sum.Sources) >= 50 {
|
||
fmt.Fprintf(&b, `<p class="note">共 %d 个独立来源,此处只列命中次数最高的 50 个。完整明细:<code>TamaBox honeypot stats --days %d</code></p>`,
|
||
sum.UniqueSources, int(sum.Period.End.Sub(sum.Period.Start).Hours()/24))
|
||
}
|
||
b.WriteString(`</section>`)
|
||
|
||
// 路径
|
||
if len(sum.Paths) > 0 {
|
||
b.WriteString(`<section><h2>被探测路径</h2>`)
|
||
b.WriteString(`<table><thead><tr><th>#</th><th>路径</th><th class="num">次数</th></tr></thead><tbody>`)
|
||
for i, p := range sum.Paths {
|
||
if i >= 20 {
|
||
break
|
||
}
|
||
fmt.Fprintf(&b, `<tr><td>%d</td><td class="mono">%s</td><td class="num">%d</td></tr>`,
|
||
i+1, html.EscapeString(p.Path), p.Hits)
|
||
}
|
||
b.WriteString(`</tbody></table></section>`)
|
||
}
|
||
|
||
// 趋势
|
||
if len(sum.Days) > 1 {
|
||
b.WriteString(`<section><h2>每日趋势</h2>`)
|
||
b.WriteString(`<table class="trend"><tbody>`)
|
||
for _, d := range sum.Days {
|
||
w := 0.0
|
||
if sum.MaxDay > 0 {
|
||
w = float64(d.Hits) / float64(sum.MaxDay) * 100
|
||
}
|
||
// 宽度为 0 的日子也给 1px,否则读起来像「数据缺失」而不是「零」。
|
||
barW := math.Max(w, 0.6)
|
||
fmt.Fprintf(&b, `<tr><td class="day">%s</td><td class="bar"><span style="width:%.2f%%"></span></td><td class="num">%d</td></tr>`,
|
||
d.Day.Format("01-02"), barW, d.Hits)
|
||
}
|
||
b.WriteString(`</tbody></table></section>`)
|
||
}
|
||
|
||
// 口径说明
|
||
b.WriteString(`<section><h2>口径与说明</h2><ul class="meta">`)
|
||
fmt.Fprintf(&b, `<li>地理定位数据源:%s。</li>`, html.EscapeString(sum.ResolverDesc))
|
||
fmt.Fprintf(&b, `<li>未能定位的命中:<b>%d</b> 次(占 %s)。这些 IP 在国内外的 IP 库里都没有城市级记录,通常是被回收中的地址段或运营商级 NAT。</li>`,
|
||
sum.UnlocatedHits, pct(sum.UnlocatedHits, sum.TotalHits))
|
||
b.WriteString(`<li>「破解」指对该次请求提交了账号密码字段;纯路径探测不计入。捕获到的账号密码本身不写进本报告,存放在服务器的取证目录(<code>TamaBox honeypot hits</code> 查看)。</li>`)
|
||
b.WriteString(`<li>「网络归属」来自离线指纹(cdncheck),判定的是云厂商 / CDN / WAF,<b>不是</b>地理位置。显示「未识别(普通网络)」不代表没定位到,两者是不同的维度。</li>`)
|
||
b.WriteString(`<li>独立来源按封禁口径聚合:IPv4 精确到地址,IPv6 折叠到 /64 —— 攻击者常在一个 /64 内轮换地址,按精确地址会把一台机器算成几十个。</li>`)
|
||
b.WriteString(`</ul></section>`)
|
||
|
||
fmt.Fprintf(&b, `<footer>由 TamaBox 蜜罐自动生成 · %s</footer>`, sum.GeneratedAt.Format("2006-01-02 15:04:05"))
|
||
b.WriteString(`</div></body></html>`)
|
||
return b.String()
|
||
}
|
||
|
||
// renderTencentMap 生成腾讯地图区块。
|
||
//
|
||
// 合规要点(与 geo-map-compliance-guard 一致):
|
||
// - 只用持牌厂商的底图(腾讯位置服务),绝不引 Google/Bing/OSM/Mapbox;
|
||
// - 代码里不预置任何可用 key,未配置时给出申请指引;
|
||
// - 地图展示的是攻击者 IP 的城市级定位,用于内部安全复盘,不对外发布。
|
||
func renderTencentMap(sum *Summary, key string) string {
|
||
key = strings.TrimSpace(key)
|
||
if key == "" {
|
||
return fmt.Sprintf(`<div class="hint">未配置腾讯地图 key,本节跳过。<br>如需可缩放的真实底图,%s。</div>`,
|
||
html.EscapeString(tencentKeyPlaceholder))
|
||
}
|
||
|
||
pts := bubbles(sum)
|
||
if len(pts) > 120 {
|
||
pts = pts[:120] // 地图上挤 500 个点既卡又看不清
|
||
}
|
||
maxHits := maxCityHits(pts)
|
||
|
||
var geo strings.Builder
|
||
geo.WriteString("[")
|
||
for i, p := range pts {
|
||
if i > 0 {
|
||
geo.WriteString(",")
|
||
}
|
||
// 腾讯地图用 GCJ-02。ip-api 给的是 WGS-84(与 GCJ-02 差几百米),
|
||
// 在城市级缩放上无影响;放大到街道时才有偏差。
|
||
fmt.Fprintf(&geo, `{"id":"p%d","styleId":"s%d","position":[%f,%f],"title":"%s %d 次"}`,
|
||
i, sizeClass(p.Hits, maxHits), p.Lat, p.Lon,
|
||
jsEscape(p.City+p.Country), p.Hits)
|
||
}
|
||
geo.WriteString("]")
|
||
|
||
return fmt.Sprintf(`<div id="tmap" class="tmap"></div>
|
||
<div id="tmap-status" class="note"></div>
|
||
<script src="https://map.qq.com/api/gljs?v=1.exp&key=%s"></script>
|
||
<script>
|
||
(function () {
|
||
if (typeof TMap === 'undefined') {
|
||
document.getElementById('tmap-status').textContent = '腾讯地图 SDK 未能加载(可能是离线打开或 key 无效),请参考上方离线地图。';
|
||
return;
|
||
}
|
||
var center = new TMap.LatLng(20, 10);
|
||
var map = new TMap.Map('tmap', { center: center, zoom: 3, viewMode: '2D' });
|
||
var styles = {};
|
||
var sizes = [[8,8],[12,12],[18,18],[26,26],[34,34]];
|
||
for (var i = 0; i < sizes.length; i++) {
|
||
styles['s' + i] = new TMap.MarkerStyle({
|
||
width: sizes[i][0], height: sizes[i][1],
|
||
anchor: { x: sizes[i][0]/2, y: sizes[i][1]/2 },
|
||
color: '#EF4444'
|
||
});
|
||
}
|
||
new TMap.MultiMarker({ map: map, styles: styles, geometries: %s });
|
||
document.getElementById('tmap-status').textContent = '共 %d 个城市级定位点,气泡越大命中越多。点击标记可见城市与次数。';
|
||
})();
|
||
</script>`,
|
||
html.EscapeString(key), geo.String(), len(pts))
|
||
}
|
||
|
||
// sizeClass 按命中数把气泡分成 5 档(地图标记的尺寸样式只有有限几档)。
|
||
func sizeClass(hits, maxHits int64) int {
|
||
if maxHits <= 0 {
|
||
return 0
|
||
}
|
||
r := float64(hits) / float64(maxHits)
|
||
switch {
|
||
case r > 0.8:
|
||
return 4
|
||
case r > 0.5:
|
||
return 3
|
||
case r > 0.25:
|
||
return 2
|
||
case r > 0.08:
|
||
return 1
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// jsEscape 转义要嵌进 JS 字符串字面量的文本。
|
||
// 先做 HTML 转义(防 </script> 逃逸),再转义引号与反斜杠。
|
||
func jsEscape(s string) string {
|
||
s = html.EscapeString(s)
|
||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||
s = strings.ReplaceAll(s, "\n", " ")
|
||
s = strings.ReplaceAll(s, "\r", " ")
|
||
return s
|
||
}
|
||
|
||
func writeKPI(b *strings.Builder, label, value, note string) {
|
||
b.WriteString(`<div class="kpi">`)
|
||
fmt.Fprintf(b, `<div class="kpi-label">%s</div><div class="kpi-value">%s</div>`, html.EscapeString(label), html.EscapeString(value))
|
||
if note != "" {
|
||
fmt.Fprintf(b, `<div class="kpi-note">%s</div>`, html.EscapeString(note))
|
||
}
|
||
b.WriteString(`</div>`)
|
||
}
|
||
|
||
// trendNote 给出与上一周期的对比。
|
||
func trendNote(cur, prev int64) string {
|
||
if prev <= 0 {
|
||
return "上期无数据"
|
||
}
|
||
delta := float64(cur-prev) / float64(prev) * 100
|
||
arrow := "↑"
|
||
if delta < 0 {
|
||
arrow = "↓"
|
||
}
|
||
return fmt.Sprintf("较上期 %s %.0f%%(上期 %d)", arrow, math.Abs(delta), prev)
|
||
}
|
||
|
||
func pct(v, total int64) string {
|
||
if total <= 0 {
|
||
return "—"
|
||
}
|
||
return fmt.Sprintf("%.1f%%", float64(v)/float64(total)*100)
|
||
}
|
||
|
||
func pctInt(v, total int64) string { return pct(v, total) }
|
||
|
||
func pctFloat(f float64) string { return fmt.Sprintf("%.1f%%", f*100) }
|
||
|
||
// WriteFiles 把报告落盘,返回 HTML 与 PNG 的路径。
|
||
//
|
||
// 目录按 0700 创建:报告里有攻击者 IP 明细,不是可以随便给别的用户读的文件。
|
||
func WriteFiles(sum *Summary, dir string, opts HTMLOptions) (htmlPath, pngPath string, err error) {
|
||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||
return "", "", err
|
||
}
|
||
base := filepath.Join(dir, sum.Period.Filename())
|
||
|
||
htmlPath = base + ".html"
|
||
if err = os.WriteFile(htmlPath, []byte(RenderHTML(sum, opts)), 0o600); err != nil {
|
||
return "", "", err
|
||
}
|
||
|
||
png, err := RenderPNG(sum)
|
||
if err != nil {
|
||
return htmlPath, "", err
|
||
}
|
||
pngPath = base + ".png"
|
||
if err = os.WriteFile(pngPath, png, 0o600); err != nil {
|
||
return htmlPath, "", err
|
||
}
|
||
return htmlPath, pngPath, nil
|
||
}
|
||
|
||
// PNGDataURI 把地图转成 data URI(备用:某些邮件客户端对 CID 附件不友好时
|
||
// 可以退化为内联 data URI —— 代价是邮件体积变大且部分客户端会拦)。
|
||
func PNGDataURI(sum *Summary) (string, error) {
|
||
b, err := RenderPNG(sum)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(b), nil
|
||
}
|
||
|
||
// pageCSS 报告页样式。刻意内联:报告的 HTML 会被单独转发、离线打开,
|
||
// 不能依赖站点的静态资源(那些 URL 在别人机器上打不开)。
|
||
const pageCSS = `<style>
|
||
:root{--fg:#0f172a;--muted:#64748b;--line:#e2e8f0;--bg:#ffffff;--accent:#dc2626;--tint:#f8fafc}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--tint);color:var(--fg);font:14px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
|
||
.wrap{max-width:1040px;margin:0 auto;padding:32px 20px 60px}
|
||
header h1{margin:0 0 4px;font-size:24px;letter-spacing:-.01em}
|
||
header .sub{margin:0;color:var(--muted);font-size:13px}
|
||
section{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:20px;margin-top:18px}
|
||
h2{font-size:15px;margin:0 0 12px;font-weight:600}
|
||
.note{color:var(--muted);font-size:12.5px;margin:8px 0 0}
|
||
.muted{color:var(--muted)}
|
||
.small{font-size:12.5px;color:var(--muted)}
|
||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px}
|
||
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-top:18px}
|
||
.kpi{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
|
||
.kpi-label{font-size:12px;color:var(--muted)}
|
||
.kpi-value{font-size:26px;font-weight:650;letter-spacing:-.02em;margin:2px 0}
|
||
.kpi-note{font-size:12px;color:var(--muted)}
|
||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||
th,td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
|
||
th{font-weight:600;color:var(--muted);font-size:12px;background:var(--tint)}
|
||
.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||
.map-frame{border:1px solid var(--line);border-radius:8px;overflow:hidden;line-height:0}
|
||
.tmap{height:420px;border:1px solid var(--line);border-radius:8px}
|
||
.hint{background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;padding:12px 14px;color:#9a3412;font-size:13px}
|
||
.empty{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:28px;text-align:center;color:var(--muted)}
|
||
table.trend td{border:0;padding:2px 8px}
|
||
table.trend .day{color:var(--muted);font-size:12px;white-space:nowrap;width:60px}
|
||
table.trend .bar{padding:0 8px}
|
||
table.trend .bar span{display:block;height:12px;background:linear-gradient(90deg,#f87171,#dc2626);border-radius:2px;min-width:1px}
|
||
table.trend .num{width:70px}
|
||
ul.meta{margin:0;padding-left:18px;color:#334155;font-size:13px}
|
||
ul.meta li{margin-bottom:6px}
|
||
footer{margin-top:26px;text-align:center;color:var(--muted);font-size:12px}
|
||
code{background:var(--tint);border:1px solid var(--line);border-radius:4px;padding:1px 5px;font-size:12px}
|
||
@media print{body{background:#fff}section{break-inside:avoid;border-color:#ccc}}
|
||
</style>`
|
||
|
||
// FormatTime 供模板层统一时间格式。
|
||
func FormatTime(t time.Time) string { return t.Format("2006-01-02 15:04") }
|