Files
subtrack/internal/web/server.go
T
chun_qiu 8b9c347649
build / build-and-test (push) Successful in 3m38s
feat(v1.10.0): 重建 Bootstrap 主题层,修复迁移遗留的明暗/缓存/布局问题
背景:v1.9.0 把界面迁到 Bootstrap 5 时保留了旧自研 CSS,v1.9.2 又叠加了一层
「墨绿渐变」覆盖层,形成三套令牌(--paper/--ink、prefers-color-scheme、
data-bs-theme)并存。系统偏好与 Cookie 不一致时,页面会出现半亮半暗、表单
标签/抽屉标题/订阅页签不可见、输入框变黑块等问题。

本次改动:
- 样式加载顺序改为 bootstrap → app.css;删除旧纸墨令牌与 v1.9.2 覆盖层,
  app.css 重写为单一主题层(:root/[data-bs-theme=light] 与 [data-bs-theme=dark]
  两套语义令牌)。组件只读 --app-*,解决 --ink-2 同时被当作「次级文字色」和
  「卡片底色」的语义冲突。
- 主题只认 <html data-bs-theme>:服务端按 Cookie 直出;没有 Cookie 时由 <head>
  内联脚本在首屏按系统偏好补齐,修好「跟随系统」并避免闪白。大屏同理。
- 修复 Bootstrap 覆盖自研设计的问题:正文 14px + 中文字体栈、22/16/14px 标题、
  按钮/输入框/焦点环/卡片回到设计值;普通按钮新增 .btn-soft,不再变透明文字。
- 布局修复:客户/套餐表单的 .grid-2 不再塞 3 个字段;包详情页把筛选独立成
  GET 表单(在搜索框回车不再误触批量开通);客户详情操作区与 meta 重排;
  抽屉时钟改为固定 46px 并用它对齐抽屉头的 sticky 偏移;追加面板在抽屉里
  两列月历、不再 1100px 竖排;手机端日历数字不再换行溢出,移动端表格隐藏
  低优先级列,不再被裁切。
- 大屏:KPI/面板去掉 Bootstrap utility,统一由 screen.css 控制;TOP/今日待打
  改为固定列宽 + grid-column 对齐;浅色主题的链接、热力、图例对比度补齐;
  热力格不再整体降透明度;当日浮层可滚动并支持点击/键盘打开。
- 配色与可读性:普通文本对比度全部达标(截图 lint 由 65 处降到 0);日历
  外月日期改为浅底深字,不再整格 opacity;archived 行改为中性底色而非降透明。
- 缓存:新增内嵌静态资源内容指纹 AssetVersion(12 位 sha256),所有 /static
  链接带 ?v=;带指纹的响应 public, max-age=31536000, immutable,不带指纹的
  回源 no-cache;HTML 继续 no-store。改样式重新编译即自动破缓存,不再依赖
  手改版本号(仍保留 v1.10.0 作为产品版本)。
- 认证页重做:登录/初始化页改用 .auth 卡片(品牌、主题切换、绿色焦点环),
  去掉 min-height:100vh 叠加 main padding 造成的 40px 纵向滚动。
- JS:修 toISOString 计算自然日差一天的时区 bug;主题切换读 data-bs-theme
  并同步 aria-pressed;全选支持半选;大屏切主题同时更新 data-bs-theme;
  提示条改为固定定位、淡出不再顶动页面。
- README 更新主题与缓存说明;版本号 1.9.5 → 1.10.0。

验证:gofmt / go vet / go test 全绿;1440、1100、390 三档,明暗两套,工作台/
客户/套餐/订阅/设置/日志/大屏/登录全部截图走查,无横向溢出与控制台错误。
2026-10-20 01:46:11 +08:00

546 lines
15 KiB
Go

// Package web 提供 HTTP 路由、会话、模板渲染与页面处理器。
package web
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"daili/internal/domain"
"daili/internal/store"
)
//go:embed templates/_layout.html templates/_screen_layout.html templates/_partials.html templates/pages/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
const (
cookieSession = "daili_sid"
cookieFlash = "daili_flash"
cookieTheme = "daili_theme"
sessionTTL = 7 * 24 * time.Hour
pageSize = 20
)
// Config 是服务配置。
type Config struct {
Addr string
DBPath string
BackupKeep int
BackupDir string
BackupEnabled bool
ScreenPublic bool
ConfigPath string
Version string
}
// Server 是应用服务。
type Server struct {
db *store.DB
cfg Config
tpl *renderer
handler http.Handler
started time.Time
assetVersion string
}
type ctxKey int
const userCtxKey ctxKey = 1
// New 构造服务。
func New(db *store.DB, cfg Config) (*Server, error) {
r, err := newRenderer()
if err != nil {
return nil, err
}
s := &Server{db: db, cfg: cfg, tpl: r, started: time.Now(), assetVersion: staticAssetVersion()}
s.handler = s.routes()
return s, nil
}
// staticAssetVersion 计算内嵌静态资源的内容指纹。
// 资源 URL 带上它之后可以放心长缓存;改了 CSS/JS 重新编译就会自动换 URL。
func staticAssetVersion() string {
h := sha256.New()
if err := fs.WalkDir(staticFS, "static", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
data, rerr := fs.ReadFile(staticFS, path)
if rerr != nil {
return nil
}
h.Write([]byte(path))
h.Write([]byte{0})
h.Write(data)
return nil
}); err != nil {
return "dev"
}
return hex.EncodeToString(h.Sum(nil))[:12]
}
// Handler 返回 HTTP 处理器。
func (s *Server) Handler() http.Handler { return s.handler }
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic(err)
}
mux.Handle("GET /static/", s.staticFiles(http.StripPrefix("/static/", http.FileServerFS(sub))))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginPost)
mux.HandleFunc("GET /setup", s.handleSetupPage)
mux.HandleFunc("POST /setup", s.handleSetupPost)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/calendar", http.StatusFound)
})
mux.HandleFunc("GET /calendar", s.auth(s.handleCalendar))
mux.HandleFunc("GET /customers", s.auth(s.handleCustomers))
mux.HandleFunc("GET /customers/new", s.auth(s.handleCustomerNewPage))
mux.HandleFunc("POST /customers", s.auth(s.handleCustomerCreate))
mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail))
mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage))
mux.HandleFunc("POST /customers/{id}/edit", s.auth(s.handleCustomerUpdate))
mux.HandleFunc("POST /customers/{id}/assignee", s.auth(s.handleCustomerAssignee))
mux.HandleFunc("POST /customers/{id}/archive", s.auth(s.handleCustomerArchive))
mux.HandleFunc("POST /customers/{id}/restore", s.auth(s.handleCustomerRestore))
mux.HandleFunc("POST /customers/{id}/delete", s.auth(s.handleCustomerDelete))
mux.HandleFunc("POST /customers/{id}/subscriptions", s.auth(s.handleSubCreate))
mux.HandleFunc("GET /subscriptions", s.auth(s.handleSubscriptions))
mux.HandleFunc("POST /subscriptions/{id}/extend", s.auth(s.handleSubExtend))
mux.HandleFunc("POST /subscriptions/{id}/pause", s.auth(s.handleSubAction("pause")))
mux.HandleFunc("POST /subscriptions/{id}/resume", s.auth(s.handleSubAction("resume")))
mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close")))
mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen")))
mux.HandleFunc("POST /subscriptions/{id}/delete", s.auth(s.handleSubDelete))
mux.HandleFunc("POST /subscriptions/{id}/season-done", s.auth(s.handleSeasonDone))
mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave))
mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete))
mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch))
mux.HandleFunc("POST /checkins/quick", s.auth(s.handleQuickCheckin))
mux.HandleFunc("POST /tasks/urgent", s.auth(s.handleUrgentToggle))
mux.HandleFunc("GET /packages", s.auth(s.handlePackages))
mux.HandleFunc("GET /packages/new", s.auth(s.handlePackageNewPage))
mux.HandleFunc("POST /packages", s.auth(s.handlePackageCreate))
mux.HandleFunc("GET /packages/{id}/edit", s.auth(s.handlePackageEditPage))
mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate))
mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive))
mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail))
mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe))
if s.cfg.ScreenPublic {
// 大屏免登录:仅局域网部署时使用,内容为只读看板
mux.HandleFunc("GET /screen", s.handleScreen)
mux.HandleFunc("GET /api/screen/day", s.handleScreenDay)
} else {
mux.HandleFunc("GET /screen", s.auth(s.handleScreen))
mux.HandleFunc("GET /api/screen/day", s.auth(s.handleScreenDay))
}
mux.HandleFunc("GET /logs", s.auth(s.handleLogs))
mux.HandleFunc("GET /settings", s.auth(s.handleSettings))
mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave))
mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange))
mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate))
mux.HandleFunc("POST /settings/users/{id}/toggle", s.auth(s.handleUserToggle))
mux.HandleFunc("POST /settings/users/{id}/color", s.auth(s.handleUserColor))
mux.HandleFunc("GET /settings/users/{id}/edit", s.auth(s.handleUserEditPage))
mux.HandleFunc("POST /settings/users/{id}/edit", s.auth(s.handleUserEditSave))
mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow))
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
return s.noCacheHTML(mux)
}
// staticFiles 给内嵌静态资源加缓存策略:
// 只有带当前内容指纹(?v=)的 URL 才允许一年不可变缓存,其它一律回源。
func (s *Server) staticFiles(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
if r.URL.Query().Get("v") == s.assetVersion {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
h.ServeHTTP(w, r)
})
}
func (s *Server) noCacheHTML(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/static/") {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
}
h.ServeHTTP(w, r)
})
}
// ---------- 会话与鉴权 ----------
func randomToken() string {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
return hex.EncodeToString(b)
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User) {
token := randomToken()
if err := s.db.CreateSession(token, u.ID, sessionTTL, clientIP(r)); err != nil {
log.Printf("创建会话失败: %v", err)
}
http.SetCookie(w, &http.Cookie{
Name: cookieSession, Value: token, Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()),
})
}
func (s *Server) userFromRequest(r *http.Request) *store.User {
c, err := r.Cookie(cookieSession)
if err != nil || c.Value == "" {
return nil
}
u, err := s.db.SessionUser(c.Value)
if err != nil {
return nil
}
return u
}
func (s *Server) currentUser(r *http.Request) *store.User {
if u, ok := r.Context().Value(userCtxKey).(*store.User); ok {
return u
}
return nil
}
func sameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return strings.EqualFold(u.Host, r.Host)
}
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && !sameOrigin(r) {
http.Error(w, "跨站请求被拒绝", http.StatusForbidden)
return
}
u := s.userFromRequest(r)
if u == nil {
if r.Method == http.MethodGet {
next := url.QueryEscape(r.URL.RequestURI())
http.Redirect(w, r, "/login?next="+next, http.StatusFound)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), userCtxKey, u)
h(w, r.WithContext(ctx))
}
}
// ---------- 渲染 ----------
type renderer struct {
pages map[string]*template.Template
}
func newRenderer() (*renderer, error) {
funcs := template.FuncMap{
"seq": func(n int) []int {
out := make([]int, 0, n)
for i := 0; i < n; i++ {
out = append(out, i)
}
return out
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"money": func(cents int) string {
return fmt.Sprintf("%.0f", float64(cents)/100)
},
"pct": func(f float64) string { return fmt.Sprintf("%.2f", f) },
"md": func(s string) string { return mdOf(s) },
"dow": dowCN,
}
base, err := template.New("base").Funcs(funcs).ParseFS(templateFS,
"templates/_layout.html", "templates/_partials.html")
if err != nil {
return nil, err
}
screenBase, err := template.New("screen").Funcs(funcs).ParseFS(templateFS,
"templates/_screen_layout.html", "templates/_partials.html")
if err != nil {
return nil, err
}
pages := map[string]*template.Template{}
entries, err := fs.Glob(templateFS, "templates/pages/*.html")
if err != nil {
return nil, err
}
for _, p := range entries {
name := strings.TrimSuffix(strings.TrimPrefix(p, "templates/pages/"), ".html")
var baseSet *template.Template
if name == "screen" {
baseSet = screenBase
} else {
baseSet = base
}
clone, err := baseSet.Clone()
if err != nil {
return nil, err
}
if _, err := clone.ParseFS(templateFS, p); err != nil {
return nil, fmt.Errorf("解析模板 %s 失败: %w", p, err)
}
pages[name] = clone
}
return &renderer{pages: pages}, nil
}
func mdOf(s string) string {
if len(s) >= 10 {
return s[5:10]
}
return s
}
func dowCN(s string) string {
t, err := domain.ParseDate(s)
if err != nil {
return ""
}
return [...]string{"一", "二", "三", "四", "五", "六", "日"}[domain.WeekdayIndex(t)]
}
// page 是所有页面模板的根数据。
type page struct {
User *store.User
Title string
Nav string
Flash string
Error string
Today time.Time
TodayStr string
WarnDays int
Version string
// AssetVersion 是内嵌静态资源的内容指纹,用于 /static/*.css?v=... 的长缓存
AssetVersion string
Query string
Next string
DueToday int
Due3 int
Due7 int
Addr string
Theme string // "" = 跟随系统;light / dark
D any
}
func (s *Server) warnDays() int {
v, err := strconv.Atoi(s.db.Setting("warn_days", "3"))
if err != nil || v <= 0 {
return 3
}
return v
}
func (s *Server) screenRefresh() int {
v, err := strconv.Atoi(s.db.Setting("screen_refresh", "30"))
if err != nil || v < 5 {
return 30
}
return v
}
func (s *Server) newPage(r *http.Request, title, nav string) *page {
u := s.currentUser(r)
today := domain.Today()
p := &page{
User: u,
Title: title,
Nav: nav,
Today: today,
TodayStr: domain.Date(today),
WarnDays: s.warnDays(),
Version: s.cfg.Version,
AssetVersion: s.assetVersion,
Query: r.URL.Query().Get("q"),
Next: r.URL.Query().Get("next"),
Theme: themeFromRequest(r),
}
if u != nil {
if due, due3, due7, err := s.db.Badges(today); err == nil {
p.DueToday, p.Due3, p.Due7 = due, due3, due7
}
}
return p
}
// themeFromRequest 读取主题偏好:"" = 跟随系统,light / dark = 手动指定。
func themeFromRequest(r *http.Request) string {
c, err := r.Cookie(cookieTheme)
if err != nil {
return ""
}
switch c.Value {
case "light", "dark":
return c.Value
}
return ""
}
func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(cookieFlash)
if err != nil || c.Value == "" {
return ""
}
msg, _ := url.QueryUnescape(c.Value)
http.SetCookie(w, &http.Cookie{Name: cookieFlash, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
return msg
}
func setFlash(w http.ResponseWriter, msg string) {
http.SetCookie(w, &http.Cookie{
Name: cookieFlash, Value: url.QueryEscape(msg), Path: "/",
MaxAge: 30, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) redirect(w http.ResponseWriter, r *http.Request, to, flash string) {
if flash != "" {
setFlash(w, flash)
}
http.Redirect(w, r, to, http.StatusSeeOther)
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, d *page) {
if d.User == nil {
d.User = s.currentUser(r)
}
if d.Flash == "" {
d.Flash = s.popFlash(w, r)
}
if d.Today.IsZero() {
d.Today = domain.Today()
d.TodayStr = domain.Date(d.Today)
}
if d.WarnDays == 0 {
d.WarnDays = s.warnDays()
}
tpl, ok := s.tpl.pages[name]
if !ok {
log.Printf("模板不存在: %s", name)
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
layout := "layout"
if name == "screen" {
layout = "screenlayout"
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, layout, d); err != nil {
log.Printf("渲染 %s 失败: %v", name, err)
http.Error(w, "页面渲染失败: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = buf.WriteTo(w)
}
func (s *Server) fail(w http.ResponseWriter, r *http.Request, name string, msg string, d *page) {
if d == nil {
d = s.newPage(r, "出错了", "")
}
d.Error = msg
w.WriteHeader(http.StatusBadRequest)
s.render(w, r, name, d)
}
// ---------- 参数 ----------
func (s *Server) queryInt(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil {
return def
}
return v
}
func (s *Server) formInt(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue(key)))
if err != nil {
return def
}
return v
}
func pathID(r *http.Request) int64 {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
return 0
}
return id
}
// backURL 取安全的返回地址。
func backURL(r *http.Request, def string) string {
if b := strings.TrimSpace(r.PostFormValue("back")); strings.HasPrefix(b, "/") && !strings.HasPrefix(b, "//") {
return b
}
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil && strings.EqualFold(u.Host, r.Host) {
p := u.RequestURI()
if strings.HasPrefix(p, "/") {
return p
}
}
}
return def
}