Files
subtrack/internal/web/handlers_calendar.go
T
chun_qiu cde1c84c9a feat(v1.11.5-L): lite 精简版——移除大屏/客户自查/多用户/小键盘,套餐仅保留按日
需求与确认(用户逐项确认):
- 老数据「按次 / 通行证 / 深渊」统一迁移成按日,代码里彻底删除三种模式
- 打手归属一并移除(只剩一个人,颜色与指派没有区分意义)
- 盲水印做成完全不可见:只存在于 DOM / 源码,肉眼与截图都看不到
- 拼写采用更正后的 Powered by Baka春秋
- 版本号与品牌全站统一带 -L
- screen_public / portal_public 保留解析但不起作用,保证旧 ini 能直接启动
- README 同步更新

品牌与版本(全站统一):
- const version = "1.11.5-L":<title>、启动横幅、日志前缀 [代打台-L]、
  设置页版本号、页脚版本号全部带 -L
- 左上角导航品牌 代打台 → 代打台-L(登录页、初始化页同步)
- 页脚新增可见署名 Powered by Baka春秋

盲水印:
- _layout.html 在 <body> 起始处注入 .wm-blind 容器(含注释与 data-wm 双标记),
  覆盖包括登录/初始化在内的所有页面
- app.css 用零尺寸 + 透明 + opacity:0 + clip-path:inset(50%) 四重隐藏,
  同时留同名注释;不占位、不上色、不进无障碍树、不影响打印

功能移除:
- 大屏:删除 handlers_screen.go、pages/screen.html、_screen_layout.html、
  static/screen.css、static/screen.js、store.Stats/ScreenStats;
  导航「大屏 ↗」、页脚「大屏」、设置页「大屏刷新/大屏」行、启动横幅大屏行一并删除
- 客户自查:删除 handlers_portal.go、pages/portal.html 与 .portal-*/.pcal-* 样式,
  设置页「客户自助」行删除
- 多管理员:删除 handleUserCreate/Toggle/Color/UserEditPage/UserEditSave、
  pages/user_form.html、store 侧 ActiveUsers/SetUserColor/SetUserActive/
  UpdateUserByAdmin/NormalizeColor/AssigneePalette;设置页「管理员」整块移除,
  只保留首次启动创建的那一个账户与「修改我的密码」
- 打手归属:删除 customers.assignee_id 相关读写与 UI(客户详情指派下拉、
  客户表单「归属打手」、日历格左侧打手色竖条、任务卡打手标签、套餐详情客户列)
- 登录页与初始化页的说明文案同步改为单账户口径(「只有一个管理员账户」
  「创建后不能再添加其他管理员」),字数与 main 对齐,卡片不换行、高度不变
- 触摸小键盘:删除 3×4 键盘、⌨/触控开关、localStorage 记忆、.keypad* 样式
- 套餐计费方式:只保留按日。删除按次/通行证/深渊的 Package 与 Subscription 字段、
  domain 里的 UnitCount/StateUsedUp/CycleIndex、package_form 计费方式单选与
  通行证/深渊/有效期字段、日历整段色块(has-block/cell-block/season-band)、
  追加次数分支、SeasonComplete/AddCount、CountActiveSubs 的次数条件

数据库迁移(幂等,Migrate 内自动执行):
- normalizeLegacyData:套餐一律 unit='day' 并清 season/abyss/cycle_days/
  default_validity_days/season_*;订阅清 season/abyss/cycle_days/season_done
- 通行证、深渊当年就是 unit='day' 且起止日完整,仅清标记,服务区间原样保留
- 按次按「1 次 = 1 天」折算:结束日 = 开始日 + 总次数 − 1;仍缺结束日退回开始日
- 只动数据,不删表不删列:老库的 season/abyss/cycle_days/total_count/used_count
  保留但不再使用,升级可逆、不会因为 DDL 失败卡住
- 新库 schema 干净:不再创建上述列,users.color / customers.assignee_id 也不创建
- 老库 ensureColumn 保留 calendar_separate / tag / archived 三项(真增量列)

兼容:
- config 仍解析 screen_public / portal_public(结构体字段注明已废弃),
  默认 ini 模板里改为「已废弃,写了不报错、不生效」

测试:
- go vet ./... 通过;go test ./... 全部通过
  (新增 TestNormalizeLegacyTypeData 验证三模式折算成按日;
   TestSingleUserAccount / TestSessionLifecycle 覆盖单用户与会话;
   删除按次、深渊、打手颜色、多用户等已不存在的能力的用例)
- 新增 scripts/smoke-lite.ps1:起隔离实例,19 个页面级请求全部 200,
  已移除的 /screen、/customer、/api/screen/day 均 404,
  水印/品牌/v1.11.5-L/无残留词 12 项断言通过,
  真实数据下日历出现待打任务、「剩 30 天」、今天到期徽标 4 项断言通过,
  本次 0 失败

README:改为 lite 版说明,删除大屏/客户自查/通行证/深渊/按次/小键盘/多用户章节,
新增「盲水印」与「与 main 分支的差异」两节(含老数据折算规则与列保留说明)。
2026-10-21 03:33:27 +08:00

539 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"daili/internal/domain"
"daili/internal/store"
)
// DayCell 是日历格。
type DayCell struct {
Date string
Day int
InMonth bool
IsToday bool
IsWeekend bool
IsSelected bool
IsPast bool
DueCount int
TaskCount int
DoneCount int
UrgentCnt int
Heat int
// PendingCount 当日还没完成的单数(= TaskCount - DoneCount)
PendingCount int
Tickets []Ticket
More int
}
// Ticket 是日历格里的色签。
type Ticket struct {
SubID int64
Tail string
Name string
Pkg string
Color string
Done bool
Skipped bool
IsDue bool
Urgent bool
}
// TaskRow 是当日待打清单的一行。
type TaskRow struct {
SubView
CheckinID int64
Done bool
Skipped bool
Note string
Operator string
Urgent bool
}
// PkgTab 是日历上方的套餐标签。
type PkgTab struct {
ID int64
Tag string
Name string
Color string
Count int
Href string
Active bool
}
type calendarData struct {
Year, Month int
Title string
PrevY, PrevM int
NextY, NextM int
TodayStr string
Weekdays []string
Weeks [][]DayCell
Selected string
SelectedLabel string
SelectedIsToday bool
SelectedIsPast bool
Tasks []TaskRow
TaskPending int
Due []SubView
OtherDue []SubView
Extend *ExtendPanel
MonthDue int
MonthTasks int
MonthDone int
MonthUrgent int
NoPackages bool
NoCustomers bool
Back string
// 套餐标签(在日历中独立显示的套餐)
Tabs []PkgTab
PkgID int64
Tag string
// FilterQuery 当前筛选条件(pkg=1 或 tag=xxx),拼链接用
FilterQuery string
HasTabs bool
// 快速完成:按尾号筛选出的候选
PickTail string
PickRows []TaskRow
PickMsg string
}
// calSub 是日历计算用的订阅上下文。
type calSub struct {
sub store.Subscription
start time.Time
end time.Time
hasEnd bool
pauses []store.Pause
checkins map[string]store.Checkin
}
func (c calSub) pausedOn(date string) bool {
for _, p := range c.pauses {
if p.StartDate == "" {
continue
}
if date >= p.StartDate && (p.EndDate == "" || date < p.EndDate) {
return true
}
}
return false
}
func (c calSub) covers(date string) bool {
if c.sub.Status == domain.StatusClosed || !c.hasEnd {
return false
}
if date < c.sub.StartDate || date > c.sub.EndDate {
return false
}
return !c.pausedOn(date)
}
// buildCalendarTabs 生成日历页签:先按套餐标签合并,再给「独立显示」的套餐各开一个。
func buildCalendarTabs(pkgs []store.Package, subs []store.Subscription, total int) ([]PkgTab, bool) {
tagOrder := []string{}
tagColor := map[string]string{}
tagCount := map[string]int{}
pkgTag := map[int64]string{}
sep := []store.Package{}
for _, p := range pkgs {
if p.Archived {
continue
}
pkgTag[p.ID] = p.Tag
if p.Tag != "" {
if _, ok := tagColor[p.Tag]; !ok {
tagOrder = append(tagOrder, p.Tag)
tagColor[p.Tag] = p.Color
}
}
if p.CalendarSeparate {
sep = append(sep, p)
}
}
for _, sub := range subs {
if t := pkgTag[sub.PackageID]; t != "" {
tagCount[t]++
}
}
if len(tagOrder) == 0 && len(sep) == 0 {
return nil, false
}
tabs := []PkgTab{{ID: 0, Name: "全部", Color: "#8A8F98", Count: total}}
for _, t := range tagOrder {
tabs = append(tabs, PkgTab{Tag: t, Name: t, Color: tagColor[t], Count: tagCount[t]})
}
for _, p := range sep {
cnt := 0
for _, sub := range subs {
if sub.PackageID == p.ID {
cnt++
}
}
tabs = append(tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color, Count: cnt})
}
return tabs, true
}
// loadCalendarSubs 读取区间内与日历相交的订阅;pkgID>0 取该套餐,tag 非空取该标签下的所有套餐。
func (s *Server) loadCalendarSubs(from, to string, pkgID int64, tag string) ([]store.Subscription, map[int64][]store.Pause, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) {
subs, err := s.db.CalendarSubs(from, to)
if err != nil {
return nil, nil, nil, nil, err
}
if pkgID > 0 || tag != "" {
pkgs, err := s.db.ListPackages(true)
if err != nil {
return nil, nil, nil, nil, err
}
tags := make(map[int64]string, len(pkgs))
for _, p := range pkgs {
tags[p.ID] = p.Tag
}
filtered := make([]store.Subscription, 0, len(subs))
for _, sub := range subs {
if pkgID > 0 && sub.PackageID != pkgID {
continue
}
if pkgID <= 0 && tag != "" && tags[sub.PackageID] != tag {
continue
}
filtered = append(filtered, sub)
}
subs = filtered
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, err := s.db.PausesForSubs(ids)
if err != nil {
return nil, nil, nil, nil, err
}
checkins, err := s.db.CheckinsBetween(from, to)
if err != nil {
return nil, nil, nil, nil, err
}
urgent, err := s.db.UrgentFlags(from, to)
if err != nil {
return nil, nil, nil, nil, err
}
return subs, pauses, checkins, urgent, nil
}
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
y := s.queryInt(r, "y", today.Year())
m := s.queryInt(r, "m", int(today.Month()))
if m < 1 || m > 12 {
y, m = today.Year(), int(today.Month())
}
monthStart := domain.MonthStart(y, time.Month(m))
monthEnd := domain.MonthEnd(y, time.Month(m))
pkgID := int64(s.queryInt(r, "pkg", 0))
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
sel := r.URL.Query().Get("d")
selT, err := domain.ParseDate(sel)
if sel != "" && err != nil {
sel = ""
}
if sel == "" {
if today.After(monthEnd) || today.Before(monthStart) {
sel = domain.Date(monthStart)
} else {
sel = domain.Date(today)
}
selT = domain.ParseDateOr(sel, today)
}
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
gridEnd := domain.AddDays(gridStart, weeks*7-1)
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID, tag)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
warn := s.warnDays()
list := make([]calSub, 0, len(subs))
for _, sub := range subs {
cs := calSub{
sub: sub,
start: domain.ParseDateOr(sub.StartDate, today),
pauses: pauses[sub.ID],
checkins: checkins[sub.ID],
}
if sub.EndDate != "" {
cs.end = domain.ParseDateOr(sub.EndDate, today)
cs.hasEnd = true
}
list = append(list, cs)
}
back := r.URL.RequestURI()
data := &calendarData{
Year: y, Month: m,
Title: fmt.Sprintf("%d 年 %d 月", y, m),
TodayStr: domain.Date(today),
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
Selected: sel,
SelectedIsToday: sel == domain.Date(today),
Back: back,
PkgID: pkgID,
Tag: tag,
FilterQuery: "pkg=" + itoa(pkgID),
}
if tag != "" {
data.FilterQuery = "tag=" + url.QueryEscape(tag)
}
data.PrevY, data.PrevM = m-1, y
if data.PrevM < 1 {
data.PrevM = 12
data.PrevY = y - 1
}
data.NextY, data.NextM = y, m+1
if data.NextM > 12 {
data.NextM = 1
data.NextY = y + 1
}
data.SelectedLabel = selT.Format("1 月 2 日") + " 周" + dowCN(sel)
data.SelectedIsPast = selT.Before(today)
// 铺格子
for wi := 0; wi < weeks; wi++ {
week := make([]DayCell, 0, 7)
for di := 0; di < 7; di++ {
day := domain.AddDays(gridStart, wi*7+di)
ds := domain.Date(day)
cell := DayCell{
Date: ds,
Day: day.Day(),
InMonth: day.Month() == time.Month(m),
IsToday: ds == domain.Date(today),
IsWeekend: di >= 5,
IsSelected: ds == sel,
IsPast: day.Before(today),
}
tickets := []Ticket{}
for _, cs := range list {
if cs.sub.EndDate == ds {
cell.DueCount++
}
if !cell.InMonth {
continue
}
if !cs.covers(ds) {
continue
}
cell.TaskCount++
ci := checkins[cs.sub.ID][ds]
isUrgent := urgent[cs.sub.ID][ds]
if isUrgent {
cell.UrgentCnt++
}
t := Ticket{
SubID: cs.sub.ID,
Tail: cs.sub.CustomerTail,
Name: cs.sub.CustomerName,
Pkg: shortName(cs.sub.PackageName),
Color: cs.sub.PackageColor,
Done: ci.Status == "done",
Skipped: ci.Status == "skipped",
IsDue: cs.sub.EndDate == ds,
Urgent: isUrgent,
}
if t.Done || t.Skipped {
cell.DoneCount++
}
tickets = append(tickets, t)
}
sort.SliceStable(tickets, func(i, j int) bool {
if tickets[i].Urgent != tickets[j].Urgent {
return tickets[i].Urgent
}
if tickets[i].Done != tickets[j].Done {
return !tickets[i].Done
}
return tickets[i].SubID < tickets[j].SubID
})
if len(tickets) > 3 {
cell.More = len(tickets) - 3
tickets = tickets[:3]
}
cell.Tickets = tickets
cell.PendingCount = cell.TaskCount - cell.DoneCount
cell.Heat = heatLevel(cell.TaskCount + cell.DueCount)
if cell.InMonth {
data.MonthDue += cell.DueCount
data.MonthTasks += cell.TaskCount
data.MonthDone += cell.DoneCount
data.MonthUrgent += cell.UrgentCnt
}
week = append(week, cell)
}
data.Weeks = append(data.Weeks, week)
}
// 当日清单
due := []SubView{}
tasks := []TaskRow{}
for _, cs := range list {
if cs.sub.EndDate == sel {
due = append(due, newSubView(cs.sub, today, warn))
}
if !cs.covers(sel) {
continue
}
row := TaskRow{SubView: newSubView(cs.sub, today, warn), Urgent: urgent[cs.sub.ID][sel]}
if ci, ok := checkins[cs.sub.ID][sel]; ok {
row.CheckinID = ci.ID
row.Done = ci.Status == "done"
row.Skipped = ci.Status == "skipped"
row.Note = ci.Note
row.Operator = ci.OperatorName
}
tasks = append(tasks, row)
}
sortTasks(tasks)
data.Tasks = tasks
for _, row := range tasks {
if !row.Done && !row.Skipped {
data.TaskPending++
}
}
data.Due = due
// 其它日期到期(不在当天的 7 天内)
if upcoming, err := s.db.RecentDue(domain.Date(today), 7); err == nil {
for _, u := range upcoming {
if u.EndDate == sel {
continue
}
if pkgID > 0 && u.PackageID != pkgID {
continue
}
if pkgID <= 0 && tag != "" {
if p, err := s.db.Package(u.PackageID); err != nil || p.Tag != tag {
continue
}
}
data.OtherDue = append(data.OtherDue, newSubView(u, today, warn))
}
}
// 页签:先按「套餐标签」合并,再给勾了独立显示的套餐各开一个
pkgs, _ := s.db.ListPackages(true)
data.Tabs, data.HasTabs = buildCalendarTabs(pkgs, subs, len(list))
for i := range data.Tabs {
t := &data.Tabs[i]
href := fmt.Sprintf("/calendar?y=%d&m=%d&d=%s", y, m, sel)
if t.Tag != "" {
href += "&tag=" + url.QueryEscape(t.Tag)
t.Active = t.Tag == tag
} else if t.ID > 0 {
href += "&pkg=" + itoa(t.ID)
t.Active = t.ID == pkgID
} else {
t.Active = tag == "" && pkgID == 0
}
t.Href = href
}
// 快速完成:按尾号列出候选
if tail := r.URL.Query().Get("pick"); tail != "" {
data.PickTail = tail
for _, row := range tasks {
if row.CustomerTail == tail {
data.PickRows = append(data.PickRows, row)
}
}
if len(data.PickRows) == 0 {
data.PickMsg = "尾号 " + tail + " 在今天没有待完成的服务"
}
}
// 追加面板
if eid := s.queryInt(r, "extend", 0); eid > 0 {
if sub, err := s.db.Sub(int64(eid)); err == nil {
if pkg, err := s.db.Package(sub.PackageID); err == nil {
data.Extend = buildExtendPanel(sub, pkg, today, back, warn)
}
}
}
if n, _ := s.db.CustomerCount(); n == 0 {
data.NoCustomers = true
}
if len(pkgs) == 0 {
data.NoPackages = true
}
p := s.newPage(r, "日历", "calendar")
p.D = data
s.render(w, r, "calendar", p)
}
// sortTasks 加急优先,其次未完成,最后按结束日。
func sortTasks(tasks []TaskRow) {
sort.SliceStable(tasks, func(i, j int) bool {
ai, aj := tasks[i], tasks[j]
if ai.Urgent != aj.Urgent {
return ai.Urgent
}
adi := ai.Done || ai.Skipped
adj := aj.Done || aj.Skipped
if adi != adj {
return !adi
}
return ai.EndDate < aj.EndDate
})
}
func heatLevel(n int) int {
switch {
case n <= 0:
return 0
case n <= 2:
return 1
case n <= 5:
return 2
case n <= 9:
return 3
default:
return 4
}
}
// shortName 取套餐名前两个字符,用作日历色签上的短标签。
func shortName(s string) string {
r := []rune(s)
if len(r) <= 2 {
return s
}
return string(r[:2])
}
// stripParam 去掉地址里的某个参数,用于拼“返回”链接。
func stripParam(raw, key string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
}
q := u.Query()
q.Del(key)
u.RawQuery = q.Encode()
return u.String()
}