Files
subtrack/internal/store/packages.go
T
subtrack 85f98dff5c feat(v1.2): 亮暗主题、按尾号快速完成、加急置顶、按套餐分日历、大屏升级
1) 全站亮色 / 暗色
- 管理端与大屏都提供切换按钮(顶栏 ☾/☀、大屏右上角),偏好写 cookie,服务端首屏直出,不闪
- 未选择时跟随系统(prefers-color-scheme);两套配色都改成 CSS 变量,色签、热力、表格、抽屉全部适配
- 大屏默认仍是深色记分板,浅色为纸感背景

2) 工作台 → 当日待完成:按手机尾号快速完成
- 输入尾号回车:唯一匹配直接完成并提示
- 同尾号多客户 / 一单多服务:跳到选择面板,默认全选,可多选后一次完成
- 新增 POST /checkins/quick 与 POST /checkins/batch;抽屉标题改为「未完成数 / 总单数」

3) 加急
- 每条任务右侧可勾选/取消加急;加急任务红 tag + 置顶(抽屉与大屏「今日待打」)
- 日历格子出现红点,单据色签加红边;大屏热力格也带红点
- 新增表 task_flags(sub_id, service_date, urgent),按 订阅+日期 存,避免和打卡状态耦合

4) 套餐「在日历中独立显示」
- 套餐表单新增勾选项,列表页显示「独立日历」标记
- 勾选后工作台与大屏顶部出现套餐标签页(全部 / 每日任务 / …),可只看某类委托的排单、热量与待办
- 新增列 packages.calendar_separate;老库打开时自动 ALTER,无需手工迁移

5) 大屏
- 热力默认改为「每日待完成单量」,右上角可切回「到期热力」
- 鼠标悬停任意日期格,浮出当日待办明细(客户、套餐、剩余、加急),数据来自 GET /api/screen/day
- 底部面板改为「今日到期」(左)+「今日待打」(右,加急置顶),最近追加改为最底部细条

数据库兼容性:
- 只做加法:新增 task_flags 表与 packages.calendar_separate 列,原有表结构、字段、接口与已有数据均不变
- 迁移可重复执行(CREATE TABLE IF NOT EXISTS + PRAGMA 检查后 ALTER)

测试:
- 新增单元测试:套餐独立日历标记、加急增删查、旧库自动加列与重复迁移
- 新增功能冒烟 33 项(主题 cookie、套餐标签、快速完成单/多匹配、加急排序、大屏接口)全部通过
- 原有 119 项验收回归全绿(3 条断言按新 UI 更新:抽屉标题、热力默认口径、今日待打数取值方式)
- go vet / go test ./... 通过;版本号 1.2.0
2026-10-18 22:07:25 +08:00

159 lines
4.2 KiB
Go

package store
import (
"database/sql"
"errors"
"strings"
"daili/internal/domain"
)
// Package 服务套餐。
type Package struct {
ID int64
Name string
Unit string
DefaultAmount int
PriceCents int
HasPrice bool
Color string
Note string
Archived bool
SortOrder int
// CalendarSeparate 勾选后在工作台/大屏日历中单独成页
CalendarSeparate bool
CreatedAt string
UpdatedAt string
SubCount int
ActiveCount int
}
// UnitLabel 返回类型中文名。
func (p *Package) UnitLabel() string {
if p.Unit == domain.UnitCount {
return "按次"
}
return "按日"
}
func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
var p Package
var price sql.NullInt64
var archived, separate int
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &separate, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
p.Archived = archived == 1
p.CalendarSeparate = separate == 1
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
}
return &p, nil
}
// ListPackages 列出套餐(含订阅计数)。
func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.created_at,p.updated_at,
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id),
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed')
FROM packages p`
if !includeArchived {
q += ` WHERE p.archived=0`
}
q += ` ORDER BY p.archived, p.sort_order, p.id`
rows, err := db.sql.Query(q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Package{}
for rows.Next() {
var p Package
var price sql.NullInt64
var archived, separate int
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &separate, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
return nil, err
}
p.Archived = archived == 1
p.CalendarSeparate = separate == 1
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
}
out = append(out, p)
}
return out, rows.Err()
}
// Package 取单个套餐。
func (db *DB) Package(id int64) (*Package, error) {
return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,created_at,updated_at
FROM packages WHERE id=?`, id))
}
// CreatePackage 新建套餐。
func (db *DB) CreatePackage(p *Package) (int64, error) {
now := nowStamp()
p.Unit = normalizeUnit(p.Unit)
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#2F5D50"
}
var price any
if p.HasPrice {
price = p.PriceCents
}
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,?,?,?,?)`,
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), now, now)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// UpdatePackage 更新套餐。
func (db *DB) UpdatePackage(p *Package) error {
p.Unit = normalizeUnit(p.Unit)
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#2F5D50"
}
var price any
if p.HasPrice {
price = p.PriceCents
}
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,updated_at=?
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), nowStamp(), p.ID)
return err
}
// b2i 把 bool 转成 SQLite 的 0/1。
func b2i(v bool) int {
if v {
return 1
}
return 0
}
// SetPackageArchived 归档/取消归档。
func (db *DB) SetPackageArchived(id int64, archived bool) error {
v := 0
if archived {
v = 1
}
_, err := db.sql.Exec(`UPDATE packages SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id)
return err
}