Files
subtrack/main.go
T
subtrack 1f7d0f0cc4
build / build-and-test (push) Successful in 3m19s
feat(v1.5): 触摸键盘、格子大字计数、客户停用/删除、按钮收纳、7 天徽标、抽屉时钟
1) 工作台速录
- 手机尾号输入框下方新增 3×4 触摸小键盘(1-9、0、⌫、✓),点 ✓ 直接提交;实体键盘仍可输入
- 抽屉顶部新增秒级时钟(HH:MM:SS),位于日期标题上方

2) 日历格子
- 移除排单缩略信息(客户名/套餐色签/“x/y 单”小字)
- 保留左侧打手颜色竖条,中间改为大字「待完成 / 总数」,例如 1/2 = 还剩 1 单、共 2 单
- 大屏格子按此前约定保持原样

3) 客户停用 / 恢复 / 彻底删除
- customers 新增 archived 列(0/1);停用后默认从客户列表、日历、待办、大屏、到期提醒、徽标中隐藏,订阅与打卡记录原样保留
- 恢复后立即回到日历;列表右上角可切换「显示已停用」,已停用行带标记
- 彻底删除为二次确认操作,依赖外键级联清理订阅、打卡、暂停、追加记录与加急标记
- 停用/恢复/删除都会写入操作日志

4) 按钮收纳
- 任务行只露出「完成」,跳过 / 加急(取消加急)/ 追加收进「⋯」菜单;已完成行只露「撤销打卡」等,点空白处自动收起
- 加急、跳过等原有能力不变,仅入口折叠

5) 顶栏
- 在「今日到期」「3 天内到期」右侧新增「7 天内到期」徽标,固定统计未来 7 天

6) 视觉
- 移除管理端背景的横线装饰,改为纯底色;大屏网格背景保留

数据库兼容性:
- 只新增 customers.archived 一列,老库启动时自动 ALTER,可重复迁移;原有表结构与接口不变

测试:
- 新增单测 TestCustomerArchiveFilters:停用后日历/到期/徽标/搜索/列表全部过滤、恢复回日历、彻底删除级联清理订阅与打卡
- 功能验证 20 项(键盘、格子计数、折叠菜单、7 天徽标、时钟、停用/恢复/删除、背景)全部通过
- 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.5.0
2026-10-19 02:12: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.5.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)
}
}