1) 管理员颜色 - users 表新增 color;设置 → 管理员 每行一个颜色选择器,保存即生效 - 新建管理员自动从 8 色调色板分配,尽量不重复;支持 #RRGGBB 校验,非法值拒绝 2) 客户归属打手 - customers 表新增 assignee_id(可空,指向 users) - 新建/编辑客户时可选择「归属打手」,客户详情页也能直接改派 - 语义:一个客户一个打手,该客户之后的所有单子都归这个打手 - 停用的账户不出现在打手候选里;未指派显示灰色「未指派」 3) 日历 - 每个日期格左侧新增 5px 竖条,按当天涉及的打手颜色等分显示(几种颜色几段) - 竖条按“当天全部任务”去重计算,不受格内色签 3 条上限影响 - 未指派的任务用中性灰段落 4) 待办与大屏 - 「当日待完成」每条任务左侧 3px 打手色条 + 圆点色标 + 打手名字,未指派灰字 - 大屏:今日待打列表、热力格竖条、悬停浮层(新增 who / who_color 字段)统一使用同一套颜色 - 加急样式改为红底,避免与打手色左边框冲突 数据库兼容性: - 只做加法:users.color、customers.assignee_id 两列;老库打开时自动 ALTER,可重复迁移 - 原有表结构、字段、页面接口不变;未指派的存量数据照常工作 测试: - 新增单测:颜色自动分配/规范化/非法值拒绝、客户指派与取消、订阅联表带出打手、停用账户不进候选 - 旧库迁移测试扩展:老 users / customers 表自动补列并可写 - 功能验证 16 项(颜色保存、指派、详情展示、日历多色竖条分段、大屏列表与浮层接口)全部通过 - 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.3.0
501 lines
13 KiB
Go
501 lines
13 KiB
Go
// Package web 提供 HTTP 路由、会话、模板渲染与页面处理器。
|
|
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"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
|
|
}
|
|
|
|
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()}
|
|
s.handler = s.routes()
|
|
return s, nil
|
|
}
|
|
|
|
// 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/", cacheControl(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}/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}/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))
|
|
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("POST /settings/backup", s.auth(s.handleBackupNow))
|
|
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
|
|
|
|
return s.noCacheHTML(mux)
|
|
}
|
|
|
|
func cacheControl(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
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
|
|
Query string
|
|
Next string
|
|
DueToday int
|
|
Due3 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,
|
|
Query: r.URL.Query().Get("q"),
|
|
Next: r.URL.Query().Get("next"),
|
|
Theme: themeFromRequest(r),
|
|
}
|
|
if u != nil {
|
|
if due, due3, err := s.db.Badges(today); err == nil {
|
|
p.DueToday, p.Due3 = due, due3
|
|
}
|
|
}
|
|
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
|
|
}
|