Files
subtrack/main.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

294 lines
8.1 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.
// 代打管理平台 · 单文件服务
//
// 双击本程序即可启动:内嵌 SQLite、模板与静态资源,无需安装任何环境。
// 部署参数读取同目录的 daili.ini(不存在会自动生成)。
package main
import (
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"syscall"
"time"
"daili/internal/config"
"daili/internal/logx"
"daili/internal/store"
"daili/internal/web"
)
const version = "1.2.0"
func main() {
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
addrFlag := flag.String("addr", "", "临时覆盖监听地址,例如 127.0.0.1:19000")
dbFlag := flag.String("db", "", "临时覆盖数据库路径")
keepFlag := flag.Int("backup-keep", 0, "临时覆盖备份保留份数")
noBackup := flag.Bool("no-backup", false, "本次运行关闭自动备份")
noOpen := flag.Bool("no-open", false, "本次运行不自动打开浏览器")
flag.Parse()
flagsSet := map[string]bool{}
flag.Visit(func(f *flag.Flag) { flagsSet[f.Name] = true })
log.SetFlags(log.LstdFlags)
log.SetPrefix("[代打台] ")
exeDir := executableDir()
cfgPath := *cfgFlag
if cfgPath == "" {
cfgPath = filepath.Join(exeDir, config.FileName)
}
cfg, err := config.Load(cfgPath, exeDir)
if err != nil {
// 配置写错时不要吞掉,直接指向具体行
fmt.Fprintf(os.Stderr, "\n[代打台] 配置文件有问题,已停止启动:\n\n%v\n\n", err)
os.Exit(1)
}
// 命令行覆盖(临时调试用,优先于配置文件)
if flagsSet["addr"] {
if err := applyAddr(cfg, *addrFlag); err != nil {
fatal("%v", err)
}
}
if flagsSet["db"] {
p := *dbFlag
if !filepath.IsAbs(p) {
p = filepath.Join(exeDir, p)
}
cfg.DatabasePath = filepath.Clean(p)
}
if flagsSet["backup-keep"] {
cfg.BackupKeep = *keepFlag
}
if *noBackup {
cfg.BackupEnabled = false
}
if *noOpen {
cfg.OpenBrowser = false
}
if err := cfg.Validate(); err != nil {
fatal("命令行参数不合法:%v", err)
}
// 运行日志:控制台 + logs/daili-YYYY-MM-DD.log
if cfg.LogEnabled {
closer, err := logx.Setup(cfg.LogDir)
if err != nil {
log.Printf("运行日志无法写入 %s:%v(继续只输出到控制台)", cfg.LogDir, err)
} else {
defer closer.Close()
log.Printf("运行日志:%s", filepath.Join(cfg.LogDir, "daili-"+time.Now().Format("2006-01-02")+".log"))
}
}
// 时区:留空跟随系统,否则固定(会影响全部“自然日”计算)
if cfg.Timezone != "" {
loc, err := time.LoadLocation(cfg.Timezone)
if err != nil {
fatal("时区 %q 无法加载:%v", cfg.Timezone, err)
}
time.Local = loc
log.Printf("时区已固定为 %s(当前时间 %s)", cfg.Timezone, time.Now().Format("2006-01-02 15:04:05"))
}
if cfg.Created {
log.Printf("未找到配置文件,已生成默认配置:%s(修改后重启生效)", cfg.Path)
} else {
log.Printf("配置文件:%s", cfg.Path)
}
db, err := store.Open(cfg.DatabasePath)
if err != nil {
fatal("打开数据库失败:%v", err)
}
defer db.Close()
if err := db.Migrate(); err != nil {
fatal("初始化数据库结构失败:%v", err)
}
db.PurgeSessions()
if cfg.BackupEnabled {
db.SetBackupDir(cfg.BackupDir)
}
if mode, err := db.JournalMode(); err == nil {
log.Printf("数据库已就绪(%s,journal_mode=%s:全部数据都在这一个文件里,复制它即可迁移)", db.Path, mode)
}
srv, err := web.New(db, web.Config{
Addr: cfg.Addr(),
DBPath: db.Path,
BackupKeep: cfg.BackupKeep,
BackupDir: cfg.BackupDir,
BackupEnabled: cfg.BackupEnabled,
ScreenPublic: cfg.ScreenPublic,
ConfigPath: cfg.Path,
Version: version,
})
if err != nil {
fatal("初始化服务失败:%v", err)
}
if cfg.BackupEnabled {
go func() {
run := func() {
path, err := db.BackupDaily(cfg.BackupKeep)
if err != nil {
log.Printf("自动备份失败:%v", err)
return
}
if path != "" {
log.Printf("已自动备份数据库:%s", path)
}
}
run()
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
run()
}
}()
}
ln, err := net.Listen("tcp", cfg.Addr())
if err != nil {
fatal("无法监听 %s:%v\n提示:端口被占用时,可修改 %s 里的 listen_port", cfg.Addr(), err, cfg.Path)
}
httpSrv := &http.Server{
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
primaryURL := printBanner(cfg, db.Path)
if cfg.OpenBrowser {
go func() {
time.Sleep(400 * time.Millisecond)
openBrowser(primaryURL)
}()
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Printf("HTTP 服务异常退出:%v", err)
}
}()
if n, _ := db.CountUsers(); n == 0 {
log.Printf("首次运行:请在浏览器中打开上面的地址,按提示创建管理员账号")
}
<-ctx.Done()
log.Printf("正在关闭服务…")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
log.Printf("关闭服务时出错:%v", err)
}
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "\n[代打台] "+format+"\n\n", args...)
os.Exit(1)
}
// applyAddr 解析 -addr(支持 ":19000" 与 "127.0.0.1:19000")。
func applyAddr(cfg *config.Config, v string) error {
host, port, err := net.SplitHostPort(v)
if err != nil {
return fmt.Errorf("-addr 格式应为 ip:端口 或 :端口,当前是 %q", v)
}
if host != "" {
cfg.ListenIP = host
}
if port != "" {
n, err := strconv.Atoi(port)
if err != nil {
return fmt.Errorf("-addr 的端口必须是数字,当前是 %q", port)
}
cfg.ListenPort = n
}
return nil
}
func executableDir() string {
exe, err := os.Executable()
if err != nil {
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return filepath.Dir(exe)
}
func printBanner(cfg *config.Config, dbPath string) string {
port := strconv.Itoa(cfg.ListenPort)
fmt.Println()
fmt.Println(" ┌───────────────────────────────────────────────┐")
fmt.Println(" │ 代 打 台 · 代打管理平台 │")
fmt.Println(" └───────────────────────────────────────────────┘")
fmt.Printf(" 版本 %s 配置文件 %s\n", version, filepath.Base(cfg.Path))
fmt.Printf(" 数据库 %s\n", dbPath)
fmt.Println(" 访问地址:")
primary := ""
switch {
case cfg.LoopbackOnly():
primary = "http://127.0.0.1:" + port
fmt.Printf(" 本机 %s\n", primary)
fmt.Println(" 仅本机可访问(listen_ip = 127.0.0.1)")
case cfg.PublicBind():
primary = "http://127.0.0.1:" + port
fmt.Printf(" 本机 %s\n", primary)
for _, ip := range web.LocalAddrs() {
fmt.Printf(" 局域网 http://%s:%s\n", ip, port)
}
default:
primary = "http://" + net.JoinHostPort(cfg.ListenIP, port)
fmt.Printf(" 地址 %s\n", primary)
}
if cfg.ScreenPublic {
fmt.Printf(" 大屏 %s/screen(免登录)\n", primary)
} else {
fmt.Printf(" 大屏 %s/screen(需登录)\n", primary)
}
if cfg.PublicBind() && cfg.ScreenPublic {
fmt.Println(" 注意:大屏免登录且监听所有网卡,同局域网内任何人都能打开 /screen")
}
fmt.Println(" 按 Ctrl+C 停止服务")
fmt.Println()
return primary
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "darwin":
cmd = exec.Command("open", url)
default:
cmd = exec.Command("xdg-open", url)
}
if err := cmd.Start(); err != nil {
log.Printf("自动打开浏览器失败(可手动访问 %s):%v", url, err)
}
}