Files
chun_qiu bc5076ee41 feat(v1.11.0): 通行证/深渊模式、淡蓝配色与触控小键盘修复
- 触控小键盘补回 3×4 网格样式:上一版重写 CSS 时漏掉了 .keypad/.key,
  按钮退化成行内小方块;现在恢复 46px 圆角键、⌫/✓ 功能键和「触控」开关态。
- 通行证(原「包赛季」):整段有效期在独立日历上按套餐色连续着色(18% 透明度),
  每一格显示同一个「已完成 / 总单数」(0/1 或 1/1);完成后色块保留并显示 1/1。
  沿用 season_done 记录完成日期,交互动线不变。
- 新增深渊模式:套餐设置周期天数 N 与周期数 X;开通时从开始日排出 X 个 N 天周期,
  结束日 = 开始日 + N×X − 1。周期内任意一天完成即算该周期完成;同一周期重复完成
  不重复计数;撤销该周期最后一条完成打卡会回退计数。独立日历连续着色,每格显示
  同一个「已完成周期 / 总周期数」。
- 全量把「包赛季」改名为「通行证」;套餐表单新增通行证 / 深渊选项,深渊显示 N/X 字段;
  客户开通表单、套餐批量开通、订阅列表、客户订阅卡片、日历待办行同步适配,
  通行证 / 深渊不再显示「追加时长」(提交也会被服务端拒绝)。
- 快速完成 / 批量完成对通行证改为整段完成,对深渊走周期打卡,不再错误地按日写打卡。
- 配色改为淡蓝 / 暗蓝:亮色用暗蓝主色 + 淡蓝纸底,暗色用海军蓝底 + 淡蓝强调;
  默认套餐色改为 #3B6EA5;大屏同步蓝调并修正热力图例与今日数字的对比度。
- 大屏页签排除通行证 / 深渊(它们不计每日热力,避免点开是空日历);「今日待打」
  仍会列出待处理的通行证 / 深渊。
- 数据迁移:packages.abyss / packages.cycle_days / subscriptions.abyss /
  subscriptions.cycle_days,老库启动自动补列。
- 修复手机端整段色块把 7 列日历撑出屏幕:日历改成 minmax(0,1fr),小屏色块缩小。
- 版本号 1.10.0 → 1.11.0。

验证:gofmt / go vet / go test 全绿;亮暗两套、1440 与 390 两档截图走查;
通行证 0/1 → 1/1、深渊 1/4 → 2/4、同周期重复完成不重复计数、撤销回退均实测通过。
2026-10-20 02:33:57 +08:00

380 lines
12 KiB
Go
Raw Permalink 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 store 负责 SQLite 连接、迁移与全部数据读写。
package store
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
"daili/internal/domain"
)
// 常见错误
var (
ErrDuplicate = errors.New("记录已存在")
ErrNotFound = errors.New("记录不存在")
)
// DB 是数据库句柄。
type DB struct {
sql *sql.DB
Path string
// BackupPath 由配置文件指定;为空时回落到数据库同级的 backup/ 目录。
BackupPath string
}
// Open 打开或创建数据库文件。
//
// 采用 DELETE 回滚日志模式而不是 WAL:这样全部数据始终在 daili.db 这一个文件里,
// 运行中不会产生 -wal/-shm 旁路文件,用户复制一个 daili.db 就能无损迁移;
// 删掉 daili.db 也就等于清空全部数据。
func Open(path string) (*DB, error) {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("创建数据目录失败: %w", err)
}
}
// 数据库文件不存在(用户删除 = 重置)时,清掉可能是旧版本残留的旁路文件,
// 避免一个孤立的 -wal 干扰新建的库。
if _, statErr := os.Stat(path); errors.Is(statErr, os.ErrNotExist) {
_ = os.Remove(path + "-wal")
_ = os.Remove(path + "-shm")
}
dsn := strings.ReplaceAll(path, "\\", "/") +
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(DELETE)&_pragma=foreign_keys(ON)&_pragma=synchronous(FULL)"
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
if err := sqlDB.Ping(); err != nil {
sqlDB.Close()
return nil, err
}
// 兜底确认:旧版本的 WAL 库在打开时会被自动 checkpoint 并切回 DELETE 模式
var mode string
if err := sqlDB.QueryRow("PRAGMA journal_mode").Scan(&mode); err == nil && !strings.EqualFold(mode, "delete") {
sqlDB.Close()
return nil, fmt.Errorf("数据库日志模式切换失败(当前 %s),请确认 daili.db 没有被其他程序占用", mode)
}
return &DB{sql: sqlDB, Path: path}, nil
}
// JournalMode 返回当前日志模式(正常应为 delete)。
func (db *DB) JournalMode() (string, error) {
var mode string
err := db.sql.QueryRow("PRAGMA journal_mode").Scan(&mode)
return mode, err
}
// Close 关闭数据库。
func (db *DB) Close() error { return db.sql.Close() }
// SQL 暴露底层连接(供备份等使用)。
func (db *DB) SQL() *sql.DB { return db.sql }
func nowStamp() string { return time.Now().Format(domain.StampLayout) }
func (db *DB) withTx(fn func(*sql.Tx) error) error {
tx, err := db.sql.Begin()
if err != nil {
return err
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
func isUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
func normalizeUnit(u string) string {
if u == domain.UnitCount {
return domain.UnitCount
}
return domain.UnitDay
}
// Migrate 幂等建表。
func (db *DB) Migrate() error {
for _, stmt := range schemaStatements {
if _, err := db.sql.Exec(stmt); err != nil {
return fmt.Errorf("迁移失败: %w\nSQL: %s", err, stmt)
}
}
// 增量列:老库升级时补上(SQLite 没有 ADD COLUMN IF NOT EXISTS)
if err := db.ensureColumn("packages", "calendar_separate",
"ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
// v1.3:管理员颜色 + 客户归属打手
if err := db.ensureColumn("users", "color",
"ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := db.ensureColumn("customers", "assignee_id",
"ALTER TABLE customers ADD COLUMN assignee_id INTEGER REFERENCES users(id)"); err != nil {
return err
}
if err := db.ensureColumn("customers", "archived",
"ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
// v1.7:套餐标签(同标签的套餐在日历里合并成一个页签)
if err := db.ensureColumn("packages", "tag",
"ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
// v1.8:包赛季套餐 + 按次默认有效期
if err := db.ensureColumn("packages", "season",
"ALTER TABLE packages ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := db.ensureColumn("packages", "default_validity_days",
"ALTER TABLE packages ADD COLUMN default_validity_days INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := db.ensureColumn("packages", "season_start",
"ALTER TABLE packages ADD COLUMN season_start TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := db.ensureColumn("packages", "season_end",
"ALTER TABLE packages ADD COLUMN season_end TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := db.ensureColumn("subscriptions", "season",
"ALTER TABLE subscriptions ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := db.ensureColumn("subscriptions", "season_done",
"ALTER TABLE subscriptions ADD COLUMN season_done TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
// v1.11:深渊模式(N 天一个周期)与通行证改名的数据字段
if err := db.ensureColumn("packages", "abyss",
"ALTER TABLE packages ADD COLUMN abyss INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := db.ensureColumn("packages", "cycle_days",
"ALTER TABLE packages ADD COLUMN cycle_days INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := db.ensureColumn("subscriptions", "abyss",
"ALTER TABLE subscriptions ADD COLUMN abyss INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := db.ensureColumn("subscriptions", "cycle_days",
"ALTER TABLE subscriptions ADD COLUMN cycle_days INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
return nil
}
// ensureColumn 在列不存在时执行 ddl,保证迁移可重复执行。
func (db *DB) ensureColumn(table, column, ddl string) error {
rows, err := db.sql.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return fmt.Errorf("读取 %s 表结构失败: %w", table, err)
}
found := false
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt any
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
if strings.EqualFold(name, column) {
found = true
}
}
rows.Close()
if found {
return nil
}
if _, err := db.sql.Exec(ddl); err != nil {
return fmt.Errorf("迁移失败(%s.%s): %w", table, column, err)
}
return nil
}
var schemaStatements = []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
color TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
last_login_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
ip TEXT
)`,
`CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
default_amount INTEGER NOT NULL DEFAULT 30,
price_cents INTEGER,
color TEXT NOT NULL DEFAULT '#3B6EA5',
note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
calendar_separate INTEGER NOT NULL DEFAULT 0,
tag TEXT NOT NULL DEFAULT '',
season INTEGER NOT NULL DEFAULT 0,
default_validity_days INTEGER NOT NULL DEFAULT 0,
season_start TEXT NOT NULL DEFAULT '',
season_end TEXT NOT NULL DEFAULT '',
abyss INTEGER NOT NULL DEFAULT 0,
cycle_days INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tail TEXT NOT NULL,
name TEXT NOT NULL,
game_name TEXT NOT NULL DEFAULT '',
game_server TEXT NOT NULL DEFAULT '',
contact TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
assignee_id INTEGER REFERENCES users(id),
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (tail, name)
)`,
`CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
package_id INTEGER NOT NULL REFERENCES packages(id),
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','closed')),
start_date TEXT NOT NULL,
end_date TEXT,
total_count INTEGER NOT NULL DEFAULT 0,
used_count INTEGER NOT NULL DEFAULT 0,
season INTEGER NOT NULL DEFAULT 0,
season_done TEXT NOT NULL DEFAULT '',
abyss INTEGER NOT NULL DEFAULT 0,
cycle_days INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
closed_at TEXT,
UNIQUE (customer_id, package_id)
)`,
`CREATE TABLE IF NOT EXISTS sub_pauses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
start_at TEXT NOT NULL,
end_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS sub_adjustments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
base_date TEXT,
old_end_date TEXT,
new_end_date TEXT,
days_added INTEGER NOT NULL DEFAULT 0,
count_added INTEGER NOT NULL DEFAULT 0,
old_total INTEGER NOT NULL DEFAULT 0,
new_total INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
operator_id INTEGER REFERENCES users(id),
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
service_date TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('done','skipped')),
note TEXT NOT NULL DEFAULT '',
operator_id INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (sub_id, service_date)
)`,
`CREATE TABLE IF NOT EXISTS op_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_id INTEGER REFERENCES users(id),
operator_name TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
entity TEXT NOT NULL DEFAULT '',
entity_id INTEGER,
summary TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS task_flags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
service_date TEXT NOT NULL,
urgent INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (sub_id, service_date)
)`,
`CREATE INDEX IF NOT EXISTS idx_task_flags_date ON task_flags(service_date)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_sub_status_end ON subscriptions(status, end_date)`,
`CREATE INDEX IF NOT EXISTS idx_sub_customer ON subscriptions(customer_id)`,
`CREATE INDEX IF NOT EXISTS idx_sub_package ON subscriptions(package_id)`,
`CREATE INDEX IF NOT EXISTS idx_cust_tail ON customers(tail)`,
`CREATE INDEX IF NOT EXISTS idx_adj_sub ON sub_adjustments(sub_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_ci_date ON checkins(service_date)`,
`CREATE INDEX IF NOT EXISTS idx_ci_sub ON checkins(sub_id, service_date)`,
`CREATE INDEX IF NOT EXISTS idx_pause_sub ON sub_pauses(sub_id)`,
`CREATE INDEX IF NOT EXISTS idx_log_created ON op_logs(created_at)`,
}
// ---------- 设置 ----------
// Setting 读取设置,缺失时返回默认值。
func (db *DB) Setting(key, def string) string {
var v string
err := db.sql.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v)
if err != nil || v == "" {
return def
}
return v
}
// SetSetting 写入设置。
func (db *DB) SetSetting(key, value string) error {
_, err := db.sql.Exec(`INSERT INTO settings(key,value) VALUES(?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
return err
}