首次推送
build / build-and-test (push) Failing after 1m24s

This commit is contained in:
2026-10-18 19:44:38 +08:00
commit f047662129
64 changed files with 10454 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
// Package config 负责读取/生成与可执行文件同级的 daili.ini。
//
// 设计约定:
// - 只放「部署类」参数;业务默认值(预警天数、大屏刷新秒数)仍在设置页(数据库)。
// - 文件不存在时自动生成带注释的默认配置;文件写错时报错并保留原文件。
// - 相对路径一律相对于可执行文件所在目录,而不是当前工作目录。
package config
import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
// 内嵌时区库:Windows 上没有 /usr/share/zoneinfo,靠它才能 LoadLocation("Asia/Shanghai")
_ "time/tzdata"
)
// FileName 是默认配置文件名。
const FileName = "daili.ini"
// Config 是最终生效的配置。
type Config struct {
ListenIP string
ListenPort int
DatabasePath string
BackupEnabled bool
BackupDir string
BackupKeep int
Timezone string
OpenBrowser bool
ScreenPublic bool
LogEnabled bool
LogDir string
// 运行时附加信息
Path string // 配置文件绝对路径
BaseDir string // 相对路径的基准目录(exe 所在目录)
Created bool // 本次是否自动生成了默认文件
keyLine map[string]int // 各配置项出现的行号,用于校验报错定位
}
// Defaults 返回内置默认值。
func Defaults() *Config {
return &Config{
ListenIP: "0.0.0.0",
ListenPort: 17777,
DatabasePath: "daili.db",
BackupEnabled: true,
BackupDir: "backup",
BackupKeep: 14,
Timezone: "",
OpenBrowser: true,
ScreenPublic: true,
LogEnabled: true,
LogDir: "logs",
}
}
// Addr 返回 net.Listen 用的监听地址。
func (c *Config) Addr() string {
return net.JoinHostPort(c.ListenIP, strconv.Itoa(c.ListenPort))
}
// LoopbackOnly 表示只监听本机回环地址。
func (c *Config) LoopbackOnly() bool {
ip := net.ParseIP(c.ListenIP)
return ip != nil && ip.IsLoopback()
}
// PublicBind 表示监听在所有网卡(局域网可访问)。
func (c *Config) PublicBind() bool {
ip := net.ParseIP(c.ListenIP)
return ip != nil && (ip.IsUnspecified())
}
// Load 读取配置;文件不存在时生成默认文件。baseDir 为空则取配置文件所在目录。
func Load(path, baseDir string) (*Config, error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, err
}
if baseDir == "" {
baseDir = filepath.Dir(abs)
}
c := Defaults()
c.Path = abs
c.BaseDir = baseDir
data, err := os.ReadFile(abs)
if errors.Is(err, os.ErrNotExist) {
if mkErr := os.MkdirAll(filepath.Dir(abs), 0o755); mkErr != nil {
return nil, fmt.Errorf("创建配置目录失败: %w", mkErr)
}
c.Created = true
if wErr := os.WriteFile(abs, []byte(templateText(c)), 0o644); wErr != nil {
return nil, fmt.Errorf("生成默认配置文件失败: %w", wErr)
}
return c.resolve(), nil
}
if err != nil {
return nil, fmt.Errorf("读取配置文件失败: %w", err)
}
if err := c.parse(string(data)); err != nil {
return nil, fmt.Errorf("%s\n修好后重新启动即可(原文件未被修改)", err)
}
return c.resolve(), nil
}
func (c *Config) resolve() *Config {
c.DatabasePath = absolute(c.BaseDir, c.DatabasePath)
c.BackupDir = absolute(c.BaseDir, c.BackupDir)
c.LogDir = absolute(c.BaseDir, c.LogDir)
return c
}
func absolute(base, p string) string {
if p == "" {
return ""
}
if filepath.IsAbs(p) {
return filepath.Clean(p)
}
return filepath.Join(base, p)
}
func (c *Config) parse(text string) error {
text = strings.TrimPrefix(text, "\ufeff")
lines := strings.Split(text, "\n")
seen := map[string]int{}
c.keyLine = map[string]int{}
for i, raw := range lines {
line := strings.TrimSpace(strings.TrimSuffix(raw, "\r"))
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if strings.HasPrefix(line, "[") {
return fmt.Errorf("第 %d 行: 不支持分节([xxx]),请直接写 key = value", i+1)
}
eq := strings.Index(line, "=")
if eq <= 0 {
return fmt.Errorf("第 %d 行: 缺少等号,正确格式是 key = value", i+1)
}
key := strings.ToLower(strings.TrimSpace(line[:eq]))
val := strings.TrimSpace(line[eq+1:])
if prev, ok := seen[key]; ok {
return fmt.Errorf("第 %d 行: 配置项 %s 与第 %d 行重复", i+1, key, prev)
}
seen[key] = i + 1
c.keyLine[key] = i + 1
if err := c.set(key, val); err != nil {
return fmt.Errorf("第 %d 行: %v", i+1, err)
}
}
if err := c.validate(); err != nil {
return err
}
return nil
}
func (c *Config) set(key, val string) error {
switch key {
case "listen_ip":
c.ListenIP = val
case "listen_port":
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("listen_port 必须是数字,当前是 %q", val)
}
c.ListenPort = n
case "database_path":
c.DatabasePath = val
case "backup_enabled":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("backup_enabled %v", err)
}
c.BackupEnabled = b
case "backup_dir":
c.BackupDir = val
case "backup_keep":
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("backup_keep 必须是数字,当前是 %q", val)
}
c.BackupKeep = n
case "timezone":
c.Timezone = val
case "open_browser":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("open_browser %v", err)
}
c.OpenBrowser = b
case "screen_public":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("screen_public %v", err)
}
c.ScreenPublic = b
case "log_enabled":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("log_enabled %v", err)
}
c.LogEnabled = b
case "log_dir":
c.LogDir = val
default:
return fmt.Errorf("未知配置项 %s(可用项见文件内的注释)", key)
}
return nil
}
func (c *Config) validate() error {
if c.ListenIP == "" {
return c.at("listen_ip", "listen_ip 不能为空(0.0.0.0 表示所有网卡,127.0.0.1 表示仅本机)")
}
if net.ParseIP(c.ListenIP) == nil {
return c.at("listen_ip", "listen_ip %q 不是合法 IP(请写 0.0.0.0 或 127.0.0.1)", c.ListenIP)
}
if c.ListenPort < 1 || c.ListenPort > 65535 {
return c.at("listen_port", "listen_port 必须在 1-65535 之间,当前是 %d", c.ListenPort)
}
if strings.TrimSpace(c.DatabasePath) == "" {
return c.at("database_path", "database_path 不能为空")
}
if c.BackupEnabled && strings.TrimSpace(c.BackupDir) == "" {
return c.at("backup_dir", "backup_enabled 为 true 时 backup_dir 不能为空")
}
if c.BackupKeep < 1 || c.BackupKeep > 365 {
return c.at("backup_keep", "backup_keep 必须在 1-365 之间,当前是 %d", c.BackupKeep)
}
if c.Timezone != "" {
if _, err := time.LoadLocation(c.Timezone); err != nil {
return c.at("timezone", "timezone %q 无法识别(示例:Asia/Shanghai;留空表示跟随系统)", c.Timezone)
}
}
if c.LogEnabled && strings.TrimSpace(c.LogDir) == "" {
return c.at("log_dir", "log_enabled 为 true 时 log_dir 不能为空")
}
return nil
}
// at 生成带行号的错误;没有行号信息(如默认值)时退化为普通错误。
func (c *Config) at(key, format string, args ...any) error {
msg := fmt.Sprintf(format, args...)
if n := c.keyLine[key]; n > 0 {
return fmt.Errorf("第 %d 行: %s", n, msg)
}
return errors.New(msg)
}
// Validate 校验配置(命令行覆盖之后也会调用一次)。
func (c *Config) Validate() error { return c.validate() }
func parseBool(v string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on", "y":
return true, nil
case "0", "false", "no", "off", "n":
return false, nil
}
return false, fmt.Errorf("必须是 true/false(也接受 1/0、yes/no、on/off),当前是 %q", v)
}
func templateText(c *Config) string {
return fmt.Sprintf(`# ============================================================
# 代打台 配置文件(daili.ini)
# 与 daili.exe 放在同一目录;修改后需要重启程序生效。
# 写法:key = value;以 # 或 ; 开头的整行是注释;不支持分节。
# 相对路径相对于 exe 所在目录,而不是当前工作目录。
# ============================================================
# ---------- 监听 ----------
# 0.0.0.0 = 所有网卡(同局域网可访问)
# 127.0.0.1 = 只允许本机访问
listen_ip = %s
listen_port = %d
# ---------- 数据与备份 ----------
database_path = %s
# 是否每日自动备份(另可在网页「设置 → 立即备份」手动备份)
backup_enabled = %t
# 备份目录;留空则使用 exe 同级 backup/
backup_dir = %s
# 备份保留份数(1-365),超出的旧备份会自动清理
backup_keep = %d
# ---------- 时间 ----------
# 留空 = 跟随系统时区;也可固定为 Asia/Shanghai
timezone = %s
# ---------- 界面 ----------
# 启动后自动打开浏览器
open_browser = %t
# 大屏 /screen 是否免登录(默认 true:适合投屏;改成 false 则需要登录)
screen_public = %t
# ---------- 日志 ----------
# 运行日志同时输出到控制台与 log_dir 下按天切分的文件
log_enabled = %t
log_dir = %s
`,
c.ListenIP,
c.ListenPort,
c.DatabasePath,
c.BackupEnabled,
c.BackupDir,
c.BackupKeep,
c.Timezone,
c.OpenBrowser,
c.ScreenPublic,
c.LogEnabled,
c.LogDir,
)
}
+168
View File
@@ -0,0 +1,168 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeFile(t *testing.T, dir, name, body string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatalf("写测试配置失败: %v", err)
}
return p
}
func TestLoadCreatesDefaultFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, FileName)
cfg, err := Load(path, dir)
if err != nil {
t.Fatalf("Load 失败: %v", err)
}
if !cfg.Created {
t.Fatal("首次加载应标记为已生成")
}
if cfg.ListenIP != "0.0.0.0" || cfg.ListenPort != 17777 {
t.Fatalf("默认监听地址不对: %s:%d", cfg.ListenIP, cfg.ListenPort)
}
if cfg.BackupKeep != 14 || !cfg.BackupEnabled || !cfg.ScreenPublic || !cfg.LogEnabled {
t.Fatalf("默认值不对: %+v", cfg)
}
// 相对路径应解析到 exe 目录
if cfg.DatabasePath != filepath.Join(dir, "daili.db") {
t.Fatalf("数据库路径=%s", cfg.DatabasePath)
}
if cfg.BackupDir != filepath.Join(dir, "backup") || cfg.LogDir != filepath.Join(dir, "logs") {
t.Fatalf("备份/日志目录=%s %s", cfg.BackupDir, cfg.LogDir)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("默认配置文件未生成: %v", err)
}
text := string(data)
for _, want := range []string{"listen_ip = 0.0.0.0", "listen_port = 17777", "代打台 配置文件(daili.ini)", "timezone ="} {
if !strings.Contains(text, want) {
t.Errorf("默认配置缺少 %q", want)
}
}
// 再读一次不应重新生成
cfg2, err := Load(path, dir)
if err != nil {
t.Fatal(err)
}
if cfg2.Created {
t.Fatal("第二次加载不应标记为已生成")
}
}
func TestLoadParsesValues(t *testing.T) {
dir := t.TempDir()
body := "\ufeff# 注释\n" +
"listen_ip = 127.0.0.1\r\n" +
"\r\n" +
"listen_port = 19000\r\n" +
" database_path = data/mydb.db \r\n" +
"backup_enabled = no\r\n" +
"backup_keep = 30\r\n" +
"timezone = Asia/Shanghai\r\n" +
"open_browser = 0\r\n" +
"screen_public = false\r\n" +
"log_enabled = on\r\n" +
"log_dir = D:\\logs\r\n"
path := writeFile(t, dir, FileName, body)
cfg, err := Load(path, dir)
if err != nil {
t.Fatalf("Load 失败: %v", err)
}
if cfg.ListenIP != "127.0.0.1" || cfg.ListenPort != 19000 {
t.Fatalf("监听=%s:%d", cfg.ListenIP, cfg.ListenPort)
}
if !cfg.LoopbackOnly() || cfg.PublicBind() {
t.Fatal("127.0.0.1 应判定为仅本机")
}
if cfg.DatabasePath != filepath.Join(dir, "data", "mydb.db") {
t.Fatalf("相对路径未按 exe 目录解析: %s", cfg.DatabasePath)
}
if cfg.BackupEnabled || cfg.BackupKeep != 30 {
t.Fatalf("备份配置=%v/%d", cfg.BackupEnabled, cfg.BackupKeep)
}
if cfg.Timezone != "Asia/Shanghai" || cfg.OpenBrowser || cfg.ScreenPublic || !cfg.LogEnabled {
t.Fatalf("其余配置不对: %+v", cfg)
}
if cfg.LogDir != "D:\\logs" {
t.Fatalf("绝对路径被改写: %s", cfg.LogDir)
}
if cfg.Addr() != "127.0.0.1:19000" {
t.Fatalf("Addr=%s", cfg.Addr())
}
}
func TestLoadRejectsBadInput(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"未知配置项", "listen_port = 1\nlisten_prt = 2\n", "未知配置项 listen_prt"},
{"端口非数字", "\nlisten_port = abc\n", "listen_port 必须是数字"},
{"端口越界", "listen_port = 70000\n", "必须在 1-65535 之间"},
{"非法IP", "listen_ip = 300.1.1.1\n", "不是合法 IP"},
{"布尔值非法", "open_browser = maybe\n", "必须是 true/false"},
{"保留份数越界", "backup_keep = 0\n", "backup_keep 必须在 1-365 之间"},
{"时区非法", "timezone = Mars/Base\n", "timezone"},
{"重复配置", "listen_port = 1\nlisten_port = 2\n", "重复"},
{"缺少等号", "listen_port 1\n", "缺少等号"},
{"不支持分节", "[net]\nlisten_port = 1\n", "不支持分节"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, FileName, c.body)
before, _ := os.ReadFile(path)
_, err := Load(path, dir)
if err == nil {
t.Fatalf("期望报错,实际通过")
}
if !strings.Contains(err.Error(), c.want) {
t.Fatalf("错误信息 %q 不含 %q", err.Error(), c.want)
}
after, _ := os.ReadFile(path)
if string(before) != string(after) {
t.Fatal("出错时不应修改原文件")
}
})
}
}
func TestErrorHasLineNumber(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, FileName, "# 注释\nlisten_ip = 0.0.0.0\nlisten_port = 99999\n")
_, err := Load(path, dir)
if err == nil {
t.Fatal("期望报错")
}
if !strings.Contains(err.Error(), "第 3 行") {
t.Fatalf("错误信息应带行号: %v", err)
}
}
func TestValidateAfterOverride(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, FileName, "listen_port = 18080\n")
cfg, err := Load(path, dir)
if err != nil {
t.Fatal(err)
}
cfg.ListenPort = 0
if err := cfg.Validate(); err == nil {
t.Fatal("覆盖后的非法端口应被拦截")
}
}
+206
View File
@@ -0,0 +1,206 @@
// Package domain 存放与存储无关的纯业务计算:日期、剩余时长、状态判定、追加换算。
package domain
import "time"
const (
DateLayout = "2006-01-02"
StampLayout = "2006-01-02 15:04:05"
)
// Today 返回本地时区当天零点。
func Today() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}
// ParseDate 解析 YYYY-MM-DD。
func ParseDate(s string) (time.Time, error) {
return time.ParseInLocation(DateLayout, s, time.Local)
}
// ParseDateOr 解析失败时返回默认值。
func ParseDateOr(s string, fallback time.Time) time.Time {
if t, err := ParseDate(s); err == nil {
return t
}
return fallback
}
// Date 格式化为 YYYY-MM-DD。
func Date(t time.Time) string { return t.Format(DateLayout) }
// DayDiff 返回 b-a 的自然日天数(不受夏令时影响)。
func DayDiff(a, b time.Time) int {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
ua := time.Date(ay, am, ad, 0, 0, 0, 0, time.UTC)
ub := time.Date(by, bm, bd, 0, 0, 0, 0, time.UTC)
return int(ub.Sub(ua).Hours() / 24.0)
}
// AddDays 加 n 天。
func AddDays(t time.Time, n int) time.Time { return t.AddDate(0, 0, n) }
// MonthStart 返回当月 1 日。
func MonthStart(y int, m time.Month) time.Time {
return time.Date(y, m, 1, 0, 0, 0, 0, time.Local)
}
// MonthEnd 返回当月最后一天。
func MonthEnd(y int, m time.Month) time.Time {
return time.Date(y, m, 1, 0, 0, 0, 0, time.Local).AddDate(0, 1, -1)
}
// WeekdayIndex 返回以周一为 0 的星期索引。
func WeekdayIndex(t time.Time) int {
wd := int(t.Weekday())
if wd == 0 {
return 6
}
return wd - 1
}
// 订阅状态(存储值)
const (
StatusActive = "active"
StatusPaused = "paused"
StatusClosed = "closed"
)
// 套餐类型
const (
UnitDay = "day"
UnitCount = "count"
)
// 派生展示状态
const (
StateActive = "active" // 进行中
StatePending = "pending" // 未开始
StatePaused = "paused" // 暂停中
StateExpired = "expired" // 已到期
StateUsedUp = "usedup" // 次数用尽
StateClosed = "closed" // 已结束
)
// 到期预警等级
const (
LevelOK = "ok"
LevelWarn = "warn"
LevelToday = "today"
LevelExpired = "expired"
LevelNone = "none" // 无期限(按次且未设有效期)
)
// Eval 是订阅的计算结果。
type Eval struct {
State string
DaysLeft int // 距结束日的天数:0=今天到期,>0=未来,-1=无结束日
RemainingDays int
RemainingCount int
Level string
}
// EvalSub 计算订阅的展示状态与剩余量。
// - unit=day:剩余天数 = end - today + 1(含当天)
// - unit=count:剩余次数 = total - used;end 为可选有效期
func EvalSub(unit, status, startDate, endDate string, total, used int, today time.Time, warnDays int) Eval {
e := Eval{Level: LevelNone, DaysLeft: -1}
if warnDays <= 0 {
warnDays = 3
}
start := ParseDateOr(startDate, today)
end, hasEnd := time.Time{}, false
if endDate != "" {
if t, err := ParseDate(endDate); err == nil {
end, hasEnd = t, true
e.DaysLeft = DayDiff(today, end)
}
}
if unit == UnitDay {
if hasEnd {
e.RemainingDays = e.DaysLeft + 1
}
if e.RemainingDays < 0 {
e.RemainingDays = 0
}
} else {
e.RemainingCount = total - used
if e.RemainingCount < 0 {
e.RemainingCount = 0
}
}
expired := false
if hasEnd && end.Before(today) {
expired = true
}
if unit == UnitCount && e.RemainingCount <= 0 {
expired = true
}
switch {
case status == StatusClosed:
e.State = StateClosed
case status == StatusPaused:
e.State = StatePaused
case expired:
if unit == UnitCount && e.RemainingCount <= 0 {
e.State = StateUsedUp
} else {
e.State = StateExpired
}
case start.After(today):
e.State = StatePending
default:
e.State = StateActive
}
// 预警等级只对「进行中 / 未开始」有意义
switch e.State {
case StateExpired:
e.Level = LevelExpired
case StateActive, StatePending:
switch {
case !hasEnd:
e.Level = LevelNone
case e.DaysLeft <= 0:
e.Level = LevelToday
case e.DaysLeft <= warnDays:
e.Level = LevelWarn
default:
e.Level = LevelOK
}
}
return e
}
// ExtendPlan 描述一次「按日追加」的换算结果。
type ExtendPlan struct {
Base time.Time // 计算起点
OldEnd time.Time
NewEnd time.Time
Days int
SameDay bool // 新结束日等于原结束日(未增加)
Backwards bool // 新结束日早于计算起点
}
// PlanExtend 根据原结束日、今天与新结束日,算出计算起点与追加天数。
// 规则(PRD R4):已到期从今天起算,未到期从原结束日次日起算。
func PlanExtend(oldEnd, newEnd, today time.Time) ExtendPlan {
p := ExtendPlan{OldEnd: oldEnd, NewEnd: newEnd}
if !oldEnd.Before(today) {
p.Base = AddDays(oldEnd, 1)
} else {
p.Base = today
}
p.Days = DayDiff(p.Base, newEnd) + 1
p.SameDay = DayDiff(oldEnd, newEnd) == 0
p.Backwards = p.Days <= 0
if p.Days < 0 {
p.Days = 0
}
return p
}
+152
View File
@@ -0,0 +1,152 @@
package domain
import (
"testing"
"time"
)
func d(y int, m time.Month, day int) time.Time {
return time.Date(y, m, day, 0, 0, 0, 0, time.Local)
}
func TestDayDiff(t *testing.T) {
cases := []struct {
a, b time.Time
want int
}{
{d(2026, 1, 1), d(2026, 1, 1), 0},
{d(2026, 1, 1), d(2026, 1, 31), 30},
{d(2026, 2, 28), d(2026, 3, 1), 1},
{d(2024, 2, 28), d(2024, 3, 1), 2}, // 闰年
{d(2026, 12, 31), d(2027, 1, 1), 1},
{d(2026, 1, 10), d(2026, 1, 1), -9},
}
for _, c := range cases {
if got := DayDiff(c.a, c.b); got != c.want {
t.Errorf("DayDiff(%s,%s)=%d want %d", Date(c.a), Date(c.b), got, c.want)
}
}
}
func TestEvalDay(t *testing.T) {
today := d(2026, 10, 18)
cases := []struct {
name string
status string
end string
wantState string
wantLevel string
wantDays int
}{
{"进行中", "active", "2026-11-16", StateActive, LevelOK, 30},
{"今天到期", "active", "2026-10-18", StateActive, LevelToday, 1},
{"3天内", "active", "2026-10-20", StateActive, LevelWarn, 3},
{"已到期", "active", "2026-10-17", StateExpired, LevelExpired, 0},
{"暂停中", "paused", "2026-11-16", StatePaused, LevelNone, 30},
{"已结束", "closed", "2026-11-16", StateClosed, LevelNone, 30},
}
for _, c := range cases {
e := EvalSub(UnitDay, c.status, "2026-10-01", c.end, 0, 0, today, 3)
if e.State != c.wantState {
t.Errorf("%s: state=%s want %s", c.name, e.State, c.wantState)
}
if e.Level != c.wantLevel {
t.Errorf("%s: level=%s want %s", c.name, e.Level, c.wantLevel)
}
if e.RemainingDays != c.wantDays {
t.Errorf("%s: remaining=%d want %d", c.name, e.RemainingDays, c.wantDays)
}
}
}
func TestEvalDayNotStarted(t *testing.T) {
today := d(2026, 10, 18)
e := EvalSub(UnitDay, "active", "2026-10-20", "2026-11-18", 0, 0, today, 3)
if e.State != StatePending {
t.Fatalf("state=%s want pending", e.State)
}
if e.RemainingDays != 32 {
t.Fatalf("remaining=%d want 32", e.RemainingDays)
}
}
func TestEvalCount(t *testing.T) {
today := d(2026, 10, 18)
// 有次数、有有效期,10 天后到期 → 正常
e := EvalSub(UnitCount, "active", "2026-10-01", "2026-10-28", 10, 4, today, 3)
if e.State != StateActive || e.Level != LevelOK || e.RemainingCount != 6 {
t.Fatalf("got state=%s level=%s remain=%d", e.State, e.Level, e.RemainingCount)
}
// 有效期就是今天 → 今天到期(而不是误判成 warn/ok)
e = EvalSub(UnitCount, "active", "2026-10-01", "2026-10-18", 10, 4, today, 3)
if e.Level != LevelToday {
t.Fatalf("level=%s want today", e.Level)
}
// 只有次数,没有有效期 → 无预警等级
e = EvalSub(UnitCount, "active", "2026-10-01", "", 10, 4, today, 3)
if e.Level != LevelNone || e.DaysLeft != -1 {
t.Fatalf("level=%s daysLeft=%d", e.Level, e.DaysLeft)
}
// 次数用尽
e = EvalSub(UnitCount, "active", "2026-10-01", "", 10, 10, today, 3)
if e.State != StateUsedUp {
t.Fatalf("state=%s want usedup", e.State)
}
// 次数还有,但有效期已过
e = EvalSub(UnitCount, "active", "2026-10-01", "2026-10-10", 10, 4, today, 3)
if e.State != StateExpired {
t.Fatalf("state=%s want expired", e.State)
}
}
func TestPlanExtend(t *testing.T) {
today := d(2026, 10, 18)
// 未到期:从原结束日次日顺延,追加天数 = 新旧结束日之差
oldEnd := d(2026, 11, 16)
newEnd := d(2026, 12, 2)
p := PlanExtend(oldEnd, newEnd, today)
if Date(p.Base) != "2026-11-17" {
t.Fatalf("base=%s", Date(p.Base))
}
if p.Days != 16 {
t.Fatalf("days=%d want 16", p.Days)
}
// 已到期:从今天起算
oldEnd = d(2026, 10, 5)
newEnd = d(2026, 11, 3)
p = PlanExtend(oldEnd, newEnd, today)
if Date(p.Base) != "2026-10-18" {
t.Fatalf("base=%s", Date(p.Base))
}
if p.Days != 17 {
t.Fatalf("days=%d want 17", p.Days)
}
// 选的日期早于计算起点 → 无效
p = PlanExtend(d(2026, 11, 16), d(2026, 11, 10), today)
if !p.Backwards || p.Days != 0 {
t.Fatalf("backwards=%v days=%d", p.Backwards, p.Days)
}
}
func TestMonthHelpers(t *testing.T) {
if got := Date(MonthEnd(2026, time.February)); got != "2026-02-28" {
t.Fatalf("MonthEnd=%s", got)
}
if got := Date(MonthEnd(2024, time.February)); got != "2024-02-29" {
t.Fatalf("MonthEnd=%s", got)
}
if got := WeekdayIndex(d(2026, 10, 18)); got != 6 { // 周日
t.Fatalf("WeekdayIndex=%d want 6", got)
}
if got := WeekdayIndex(d(2026, 10, 19)); got != 0 { // 周一
t.Fatalf("WeekdayIndex=%d want 0", got)
}
}
+72
View File
@@ -0,0 +1,72 @@
// Package logx 提供「控制台 + 按天切分文件」的运行日志。
package logx
import (
"io"
"log"
"os"
"path/filepath"
"sync"
"time"
)
type dailyWriter struct {
dir string
mu sync.Mutex
f *os.File
day string
}
func (w *dailyWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
day := time.Now().Format("2006-01-02")
if w.f == nil || w.day != day {
if w.f != nil {
_ = w.f.Close()
w.f = nil
}
if err := os.MkdirAll(w.dir, 0o755); err != nil {
return 0, err
}
f, err := os.OpenFile(filepath.Join(w.dir, "daili-"+day+".log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return 0, err
}
w.f = f
w.day = day
}
// 写文件失败不能影响控制台输出
n, err := w.f.Write(p)
return n, err
}
func (w *dailyWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.f == nil {
return nil
}
err := w.f.Close()
w.f = nil
return err
}
// Setup 把标准日志同时写到控制台与 dir 下按天切分的文件。
// 返回的 Closer 在进程退出前关闭即可。
func Setup(dir string) (io.Closer, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
w := &dailyWriter{dir: dir}
// 预热:启动即创建当天文件,避免“刚启动就崩、日志却没落盘”
if _, err := w.Write(nil); err != nil {
return nil, err
}
log.SetOutput(io.MultiWriter(os.Stderr, w))
return w, nil
}
+48
View File
@@ -0,0 +1,48 @@
package logx
import (
"log"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSetupWritesFile(t *testing.T) {
dir := t.TempDir()
logDir := filepath.Join(dir, "logs")
closer, err := Setup(logDir)
if err != nil {
t.Fatalf("Setup 失败: %v", err)
}
log.Printf("hello-logx-%d", 42)
if err := closer.Close(); err != nil {
t.Fatalf("关闭失败: %v", err)
}
entries, err := os.ReadDir(logDir)
if err != nil || len(entries) != 1 {
t.Fatalf("日志目录应有 1 个文件, 实际 %d: %v", len(entries), err)
}
data, err := os.ReadFile(filepath.Join(logDir, entries[0].Name()))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "hello-logx-42") {
t.Fatalf("日志未落盘,文件内容: %q", string(data))
}
}
func TestSetupCreatesFileImmediately(t *testing.T) {
dir := t.TempDir()
closer, err := Setup(dir)
if err != nil {
t.Fatal(err)
}
defer closer.Close()
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 {
t.Fatalf("Setup 后应立刻存在当天日志文件: %v %d", err, len(entries))
}
}
+81
View File
@@ -0,0 +1,81 @@
// Package pw 提供口令散列(PBKDF2-HMAC-SHA256),仅用标准库。
package pw
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"strconv"
"strings"
)
const iterations = 120000
// Hash 生成 `pbkdf2-sha256$迭代次数$盐$摘要` 形式的字符串。
func Hash(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
dk := pbkdf2Key([]byte(password), salt, iterations, 32)
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
iterations,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(dk)), nil
}
// Verify 校验口令,常量时间比较。
func Verify(password, encoded string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
iter, err := strconv.Atoi(parts[1])
if err != nil || iter <= 0 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil || len(want) == 0 {
return false
}
got := pbkdf2Key([]byte(password), salt, iter, len(want))
return subtle.ConstantTimeCompare(got, want) == 1
}
func pbkdf2Key(password, salt []byte, iter, keyLen int) []byte {
prf := hmac.New(sha256.New, password)
hashLen := prf.Size()
numBlocks := (keyLen + hashLen - 1) / hashLen
var buf [4]byte
dk := make([]byte, 0, numBlocks*hashLen)
u := make([]byte, hashLen)
for block := 1; block <= numBlocks; block++ {
prf.Reset()
prf.Write(salt)
buf[0] = byte(block >> 24)
buf[1] = byte(block >> 16)
buf[2] = byte(block >> 8)
buf[3] = byte(block)
prf.Write(buf[:4])
dk = prf.Sum(dk)
t := dk[len(dk)-hashLen:]
copy(u, t)
for n := 2; n <= iter; n++ {
prf.Reset()
prf.Write(u)
u = u[:0]
u = prf.Sum(u)
for x := range u {
t[x] ^= u[x]
}
}
}
return dk[:keyLen]
}
+113
View File
@@ -0,0 +1,113 @@
package store
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// SetBackupDir 设置备份目录(来自配置文件)。
func (db *DB) SetBackupDir(dir string) { db.BackupPath = dir }
// BackupDir 返回备份目录。
func (db *DB) BackupDir() string {
if db.BackupPath != "" {
return db.BackupPath
}
return filepath.Join(filepath.Dir(db.Path), "backup")
}
// BackupDaily 当天首次调用时生成一份备份;已有今日备份则跳过。
// 空库(还没有客户)不生成备份,避免第一天的备份是空壳。
func (db *DB) BackupDaily(keep int) (string, error) {
dir := db.BackupDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
now := time.Now()
prefix := "daili-" + now.Format("20060102") + "-"
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), prefix) {
return "", nil
}
}
if n, err := db.CustomerCount(); err != nil || n == 0 {
return "", err
}
target := filepath.Join(dir, fmt.Sprintf("daili-%s.db", now.Format("20060102-150405")))
return db.BackupTo(target, keep)
}
// BackupNow 立即生成一份备份。
func (db *DB) BackupNow(keep int) (string, error) {
dir := db.BackupDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
target := filepath.Join(dir, fmt.Sprintf("daili-%s.db", time.Now().Format("20060102-150405")))
return db.BackupTo(target, keep)
}
// BackupTo 用 VACUUM INTO 生成一致性备份,并按保留份数清理。
func (db *DB) BackupTo(target string, keep int) (string, error) {
abs, err := filepath.Abs(target)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return "", fmt.Errorf("创建备份目录失败: %w", err)
}
if _, err := os.Stat(abs); err == nil {
return abs, fmt.Errorf("备份文件已存在: %s", filepath.Base(abs))
}
escaped := strings.ReplaceAll(strings.ReplaceAll(abs, "\\", "/"), "'", "''")
if _, err := db.sql.Exec("VACUUM INTO '" + escaped + "'"); err != nil {
return "", err
}
if keep > 0 {
_ = db.pruneBackups(keep)
}
return abs, nil
}
// ListBackups 列出备份(新→旧)。
func (db *DB) ListBackups() ([]string, error) {
dir := db.BackupDir()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".db") {
out = append(out, e.Name())
}
}
sort.Sort(sort.Reverse(sort.StringSlice(out)))
return out, nil
}
func (db *DB) pruneBackups(keep int) error {
list, err := db.ListBackups()
if err != nil {
return err
}
if len(list) <= keep {
return nil
}
dir := db.BackupDir()
for _, name := range list[keep:] {
_ = os.Remove(filepath.Join(dir, name))
}
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package store
import (
"database/sql"
"errors"
"daili/internal/domain"
)
// Checkin 每日服务打卡。
type Checkin struct {
ID int64
SubID int64
ServiceDate string
Status string
Note string
OperatorID int64
OperatorName string
CreatedAt string
UpdatedAt string
}
// SaveCheckin 打卡(同订阅同日覆盖),按次套餐完成时扣次。
func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int64) error {
if status != "done" && status != "skipped" {
return errors.New("打卡状态不正确")
}
if _, err := domain.ParseDate(date); err != nil {
return errors.New("日期格式不正确")
}
return db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,无法打卡")
}
var oldStatus string
err = tx.QueryRow(`SELECT status FROM checkins WHERE sub_id=? AND service_date=?`, subID, date).Scan(&oldStatus)
hasOld := err == nil
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
delta := 0
if s.Unit == domain.UnitCount {
oldDone := hasOld && oldStatus == "done"
newDone := status == "done"
switch {
case newDone && !oldDone:
if s.UsedCount+1 > s.TotalCount {
return errors.New("剩余次数不足,请先追加次数")
}
delta = 1
case !newDone && oldDone:
delta = -1
}
}
now := nowStamp()
if hasOld {
if _, err := tx.Exec(`UPDATE checkins SET status=?, note=?, operator_id=?, updated_at=? WHERE sub_id=? AND service_date=?`,
status, note, nullIfZero(operatorID), now, subID, date); err != nil {
return err
}
} else {
if _, err := tx.Exec(`INSERT INTO checkins(sub_id,service_date,status,note,operator_id,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)`, subID, date, status, note, nullIfZero(operatorID), now, now); err != nil {
return err
}
}
if delta != 0 {
newUsed := s.UsedCount + delta
if newUsed < 0 {
newUsed = 0
}
if _, err := tx.Exec(`UPDATE subscriptions SET used_count=?, updated_at=? WHERE id=?`, newUsed, now, subID); err != nil {
return err
}
}
return nil
})
}
// DeleteCheckin 撤销打卡(按次套餐返还次数),返回所属订阅 ID。
func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) {
var subID int64
var status string
var date string
err := db.withTx(func(tx *sql.Tx) error {
if err := tx.QueryRow(`SELECT sub_id,status,service_date FROM checkins WHERE id=?`, id).
Scan(&subID, &status, &date); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.Exec(`DELETE FROM checkins WHERE id=?`, id); err != nil {
return err
}
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit == domain.UnitCount && status == "done" {
newUsed := s.UsedCount - 1
if newUsed < 0 {
newUsed = 0
}
if _, err := tx.Exec(`UPDATE subscriptions SET used_count=?, updated_at=? WHERE id=?`,
newUsed, nowStamp(), subID); err != nil {
return err
}
}
return nil
})
return subID, err
}
// CheckinsBetween 取区间内打卡,索引为 [subID][date]。
func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, error) {
out := map[int64]map[string]Checkin{}
rows, err := db.sql.Query(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
WHERE c.service_date BETWEEN ? AND ?`, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var c Checkin
if err := rows.Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID,
&c.OperatorName, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
if out[c.SubID] == nil {
out[c.SubID] = map[string]Checkin{}
}
out[c.SubID][c.ServiceDate] = c
}
return out, rows.Err()
}
// CheckinsForSub 某订阅的打卡历史。
func (db *DB) CheckinsForSub(subID int64, limit int) ([]Checkin, error) {
rows, err := db.sql.Query(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
WHERE c.sub_id=? ORDER BY c.service_date DESC LIMIT ?`, subID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Checkin{}
for rows.Next() {
var c Checkin
if err := rows.Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID,
&c.OperatorName, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// CheckinFor 取某订阅某天的打卡。
func (db *DB) CheckinFor(subID int64, date string) (*Checkin, error) {
var c Checkin
err := db.sql.QueryRow(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
WHERE c.sub_id=? AND c.service_date=?`, subID, date).
Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID, &c.OperatorName, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &c, nil
}
+201
View File
@@ -0,0 +1,201 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"daili/internal/domain"
)
// Customer 客户。
type Customer struct {
ID int64
Tail string
Name string
GameName string
GameServer string
Contact string
Tags string
Source string
Note string
CreatedAt string
UpdatedAt string
// 列表聚合
SubCount int
Summary string
Level string // ok | warn | today | expired | none
}
// TagList 拆分标签。
func (c *Customer) TagList() []string {
var out []string
for _, t := range strings.Split(c.Tags, ",") {
t = strings.TrimSpace(t)
if t != "" {
out = append(out, t)
}
}
return out
}
const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at`
func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
var c Customer
err := row.Scan(&c.ID, &c.Tail, &c.Name, &c.GameName, &c.GameServer, &c.Contact,
&c.Tags, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &c, nil
}
// CreateCustomer 新建客户(尾号+姓名唯一)。
func (db *DB) CreateCustomer(c *Customer) (int64, error) {
now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, now, now)
if err != nil {
if isUnique(err) {
return 0, ErrDuplicate
}
return 0, err
}
return res.LastInsertId()
}
// UpdateCustomer 更新客户。
func (db *DB) UpdateCustomer(c *Customer) error {
_, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,updated_at=?
WHERE id=?`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, nowStamp(), c.ID)
if err != nil && isUnique(err) {
return ErrDuplicate
}
return err
}
// Customer 取单个客户。
func (db *DB) Customer(id int64) (*Customer, error) {
return scanCustomer(db.sql.QueryRow(`SELECT `+customerCols+` FROM customers WHERE id=?`, id))
}
// ListCustomers 分页查询客户,并聚合订阅摘要。
func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays int) ([]Customer, int, error) {
where := "1=1"
args := []any{}
if q = strings.TrimSpace(q); q != "" {
like := "%" + q + "%"
where = "(tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)"
args = append(args, like, like, like, like, like)
}
var total int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := db.sql.Query(`SELECT `+customerCols+` FROM customers WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`,
append(args, limit, offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []Customer{}
ids := []int64{}
for rows.Next() {
c, err := scanCustomer(rows)
if err != nil {
return nil, 0, err
}
out = append(out, *c)
ids = append(ids, c.ID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if len(ids) > 0 {
subs, err := db.SubsByCustomers(ids)
if err != nil {
return nil, 0, err
}
byCustomer := map[int64][]Subscription{}
for _, s := range subs {
byCustomer[s.CustomerID] = append(byCustomer[s.CustomerID], s)
}
for i := range out {
list := byCustomer[out[i].ID]
out[i].SubCount = len(list)
out[i].Summary, out[i].Level = summarizeSubs(list, today, warnDays)
}
}
return out, total, nil
}
// summarizeSubs 生成“包月日常 剩12天 · 深渊 剩3次”式的摘要与最紧急等级。
func summarizeSubs(subs []Subscription, today string, warnDays int) (string, string) {
if len(subs) == 0 {
return "暂无订阅", domain.LevelNone
}
t := domain.ParseDateOr(today, domain.Today())
rank := map[string]int{domain.LevelExpired: 4, domain.LevelToday: 3, domain.LevelWarn: 2, domain.LevelOK: 1, domain.LevelNone: 0}
worst := domain.LevelNone
parts := []string{}
for _, s := range subs {
e := s.Eval(t, warnDays)
if e.State == domain.StateClosed {
continue
}
var text string
if s.Unit == domain.UnitDay {
switch {
case e.State == domain.StateExpired:
text = s.PackageName + " 已过期"
case e.DaysLeft == 0:
text = s.PackageName + " 今天到期"
default:
text = fmt.Sprintf("%s 剩%d天", s.PackageName, e.RemainingDays)
}
} else {
switch {
case e.RemainingCount <= 0:
text = s.PackageName + " 已用尽"
case e.State == domain.StateExpired:
text = s.PackageName + " 已过期"
default:
text = fmt.Sprintf("%s 剩%d次", s.PackageName, e.RemainingCount)
}
}
parts = append(parts, text)
if rank[e.Level] > rank[worst] {
worst = e.Level
}
}
if len(parts) == 0 {
return "仅历史订阅", domain.LevelNone
}
if len(parts) > 2 {
parts = append(parts[:2], fmt.Sprintf("等%d项", len(parts)))
}
return strings.Join(parts, " · "), worst
}
// FindCustomerByTailName 精确查找(用于查重提示)。
func (db *DB) FindCustomerByTailName(tail, name string) (*Customer, error) {
return scanCustomer(db.sql.QueryRow(`SELECT `+customerCols+` FROM customers WHERE tail=? AND name=?`,
strings.TrimSpace(tail), strings.TrimSpace(name)))
}
// CustomerCount 客户总数。
func (db *DB) CustomerCount() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers`).Scan(&n)
return n, err
}
+259
View File
@@ -0,0 +1,259 @@
// 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)
}
}
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,
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 '#2F5D50',
note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
sort_order 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 '',
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,
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 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
}
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"fmt"
"time"
"daili/internal/domain"
)
// ExportRowSet 是导出的表头与数据行。
type ExportRowSet struct {
Header []string
Rows [][]string
}
// ExportCustomers 导出客户。
func (db *DB) ExportCustomers() (*ExportRowSet, error) {
rows, err := db.sql.Query(`SELECT id,tail,name,game_name,game_server,contact,tags,source,note,created_at FROM customers ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := &ExportRowSet{Header: []string{"ID", "手机尾号", "客户名", "游戏名", "区服/UID", "联系方式", "标签", "来源", "备注", "创建时间"}}
for rows.Next() {
var id int64
var tail, name, game, server, contact, tags, source, note, created string
if err := rows.Scan(&id, &tail, &name, &game, &server, &contact, &tags, &source, &note, &created); err != nil {
return nil, err
}
out.Rows = append(out.Rows, []string{fmt.Sprint(id), tail, name, game, server, contact, tags, source, note, created})
}
return out, rows.Err()
}
// ExportSubscriptions 导出订阅(含派生剩余量)。
func (db *DB) ExportSubscriptions(today time.Time, warnDays int) (*ExportRowSet, error) {
subs, _, err := db.ListSubs(SubFilter{Today: today, Limit: 100000})
if err != nil {
return nil, err
}
out := &ExportRowSet{Header: []string{"订阅ID", "手机尾号", "客户名", "套餐", "类型", "状态", "开始日", "结束日/有效期",
"总次数", "已用", "剩余", "原文状态", "备注", "更新时间"}}
stateLabel := map[string]string{
domain.StateActive: "进行中", domain.StatePending: "未开始", domain.StatePaused: "暂停中",
domain.StateExpired: "已到期", domain.StateUsedUp: "次数用尽", domain.StateClosed: "已结束",
}
for _, s := range subs {
e := s.Eval(today, warnDays)
remaining := ""
if s.Unit == domain.UnitDay {
remaining = fmt.Sprintf("%d 天", e.RemainingDays)
} else {
remaining = fmt.Sprintf("%d 次", e.RemainingCount)
}
out.Rows = append(out.Rows, []string{
fmt.Sprint(s.ID), s.CustomerTail, s.CustomerName, s.PackageName,
map[string]string{domain.UnitDay: "按日", domain.UnitCount: "按次"}[s.Unit],
stateLabel[e.State], s.StartDate, s.EndDate,
fmt.Sprint(s.TotalCount), fmt.Sprint(s.UsedCount), remaining, s.Status, s.Note, s.UpdatedAt,
})
}
return out, nil
}
// ExportLogs 导出操作日志。
func (db *DB) ExportLogs() (*ExportRowSet, error) {
rows, err := db.sql.Query(`SELECT created_at,operator_name,action,entity,summary,ip FROM op_logs ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := &ExportRowSet{Header: []string{"时间", "操作人", "动作", "对象", "摘要", "IP"}}
for rows.Next() {
var created, operator, action, entity, summary, ip string
if err := rows.Scan(&created, &operator, &action, &entity, &summary, &ip); err != nil {
return nil, err
}
out.Rows = append(out.Rows, []string{created, operator, action, entity, summary, ip})
}
return out, rows.Err()
}
+73
View File
@@ -0,0 +1,73 @@
package store
import (
"strings"
)
// OpLog 操作日志。
type OpLog struct {
ID int64
OperatorID int64
OperatorName string
Action string
Entity string
EntityID int64
Summary string
Detail string
IP string
CreatedAt string
}
// Log 写一条操作日志。
func (db *DB) Log(l OpLog) error {
_, err := db.sql.Exec(`INSERT INTO op_logs(operator_id,operator_name,action,entity,entity_id,summary,detail,ip,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
nullIfZero(l.OperatorID), l.OperatorName, l.Action, l.Entity, nullIfZero(l.EntityID),
l.Summary, l.Detail, l.IP, nowStamp())
return err
}
func nullIfZero(v int64) any {
if v == 0 {
return nil
}
return v
}
// ListLogs 分页查询日志。
func (db *DB) ListLogs(q string, limit, offset int) ([]OpLog, int, error) {
where := "1=1"
args := []any{}
if q = strings.TrimSpace(q); q != "" {
like := "%" + q + "%"
where = "(summary LIKE ? OR action LIKE ? OR operator_name LIKE ? OR entity LIKE ?)"
args = append(args, like, like, like, like)
}
var total int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM op_logs WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := db.sql.Query(`SELECT id,IFNULL(operator_id,0),operator_name,action,entity,IFNULL(entity_id,0),summary,detail,ip,created_at
FROM op_logs WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, append(args, limit, offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []OpLog{}
for rows.Next() {
var l OpLog
if err := rows.Scan(&l.ID, &l.OperatorID, &l.OperatorName, &l.Action, &l.Entity, &l.EntityID,
&l.Summary, &l.Detail, &l.IP, &l.CreatedAt); err != nil {
return nil, 0, err
}
out = append(out, l)
}
return out, total, rows.Err()
}
// LogCount 日志总数。
func (db *DB) LogCount() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM op_logs`).Scan(&n)
return n, err
}
+146
View File
@@ -0,0 +1,146 @@
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
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 int
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
p.Archived = archived == 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.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 int
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
return nil, err
}
p.Archived = archived == 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,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,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,?,?,?)`,
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, 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=?,updated_at=?
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, nowStamp(), p.ID)
return err
}
// 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
}
+114
View File
@@ -0,0 +1,114 @@
package store
import (
"time"
"daili/internal/domain"
)
// Stats 大屏统计数据。
type Stats struct {
Customers int
ActiveSubs int
PausedSubs int
DueToday int
Due7 int
DueTodaySubs []Subscription
Heat map[string]int // 结束日 -> 该日到期数量
MonthDue int
Top []Subscription
TodayTasks int
TodayDone int
}
// Badges 顶栏常驻徽标。
func (db *DB) Badges(today time.Time) (dueToday, due3 int, err error) {
t := domain.Date(today)
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`, t).Scan(&dueToday); err != nil {
return
}
err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`,
t, domain.Date(domain.AddDays(today, 2))).Scan(&due3)
return
}
// ScreenStats 汇总大屏所需数据。
func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) {
st := &Stats{Heat: map[string]int{}}
var err error
if st.Customers, err = db.CustomerCount(); err != nil {
return nil, err
}
if st.ActiveSubs, err = db.CountActiveSubs(domain.Date(today)); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status='paused'`).Scan(&st.PausedSubs); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`,
domain.Date(today)).Scan(&st.DueToday); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`,
domain.Date(today), domain.Date(domain.AddDays(today, 6))).Scan(&st.Due7); err != nil {
return nil, err
}
if st.DueTodaySubs, err = db.DueOn(domain.Date(today)); err != nil {
return nil, err
}
// 本月到期热力
ms := domain.MonthStart(today.Year(), today.Month())
me := domain.MonthEnd(today.Year(), today.Month())
rows, err := db.sql.Query(`SELECT end_date, COUNT(*) FROM subscriptions
WHERE status<>'closed' AND end_date BETWEEN ? AND ? GROUP BY end_date`,
domain.Date(ms), domain.Date(me))
if err != nil {
return nil, err
}
for rows.Next() {
var d string
var n int
if err := rows.Scan(&d, &n); err != nil {
rows.Close()
return nil, err
}
st.Heat[d] = n
st.MonthDue += n
}
rows.Close()
// TOP:按日按剩余天数、按次按剩余次数,各自排序后交替取前 10
t := domain.Date(today)
daySubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.unit='day' AND s.end_date>=?
ORDER BY s.end_date ASC LIMIT 12`, t)
if err != nil {
return nil, err
}
cntSubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.unit='count' AND s.used_count < s.total_count
AND (IFNULL(s.end_date,'')='' OR s.end_date>=?)
ORDER BY (s.total_count - s.used_count) ASC LIMIT 12`, t)
if err != nil {
return nil, err
}
for i := 0; i < 10 && len(st.Top) < 10; i++ {
if i < len(daySubs) {
st.Top = append(st.Top, daySubs[i])
}
if len(st.Top) < 10 && i < len(cntSubs) {
st.Top = append(st.Top, cntSubs[i])
}
if i >= len(daySubs) && i >= len(cntSubs) {
break
}
}
_ = warnDays
return st, nil
}
// RecentDue 返回未来 n 天内到期的订阅(按到期日排序)。
func (db *DB) RecentDue(from string, days int) ([]Subscription, error) {
to := domain.Date(domain.AddDays(domain.ParseDateOr(from, domain.Today()), days-1))
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date BETWEEN ? AND ?
ORDER BY s.end_date, s.id`, from, to)
}
+649
View File
@@ -0,0 +1,649 @@
package store
import (
"database/sql"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"daili/internal/domain"
)
func newTestDB(t *testing.T) *DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("打开数据库失败: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("迁移失败: %v", err)
}
return db
}
func mustPackage(t *testing.T, db *DB, name, unit string, amount int) *Package {
t.Helper()
id, err := db.CreatePackage(&Package{Name: name, Unit: unit, DefaultAmount: amount, Color: "#2F5D50"})
if err != nil {
t.Fatalf("创建套餐失败: %v", err)
}
p, err := db.Package(id)
if err != nil {
t.Fatalf("读取套餐失败: %v", err)
}
return p
}
func mustCustomer(t *testing.T, db *DB, tail, name string) int64 {
t.Helper()
id, err := db.CreateCustomer(&Customer{Tail: tail, Name: name})
if err != nil {
t.Fatalf("创建客户失败: %v", err)
}
return id
}
func TestCreateSubAndDuplicate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
if err != nil {
t.Fatalf("创建订阅失败: %v", err)
}
if sub.EndDate != domain.Date(domain.AddDays(today, 29)) {
t.Fatalf("结束日=%s", sub.EndDate)
}
if sub.Unit != domain.UnitDay || sub.Status != domain.StatusActive {
t.Fatalf("unit=%s status=%s", sub.Unit, sub.Status)
}
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
}); err == nil {
t.Fatal("同一客户同一套餐应禁止重复订阅")
}
// 初始记录
adj, err := db.AdjustmentsBySub(sub.ID)
if err != nil {
t.Fatalf("读取追加记录失败: %v", err)
}
if len(adj) != 1 || adj[0].Kind != "initial" || adj[0].DaysAdded != 30 {
t.Fatalf("初始追加记录不正确: %+v", adj)
}
}
func TestExtendDaysFromTodayWhenExpired(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.AddDays(today, -40)), Days: 30,
})
if err != nil {
t.Fatal(err)
}
if sub.EndDate != domain.Date(domain.AddDays(today, -11)) {
t.Fatalf("end=%s", sub.EndDate)
}
newEnd := domain.AddDays(today, 29)
plan, err := db.ExtendDays(sub.ID, domain.Date(newEnd), "续费", 0)
if err != nil {
t.Fatalf("追加失败: %v", err)
}
if domain.Date(plan.Base) != domain.Date(today) {
t.Fatalf("已到期订阅应从今天起算,实际 %s", domain.Date(plan.Base))
}
if plan.Days != 30 {
t.Fatalf("追加天数=%d want 30", plan.Days)
}
got, _ := db.Sub(sub.ID)
if got.EndDate != domain.Date(newEnd) {
t.Fatalf("新结束日=%s", got.EndDate)
}
if got.Status != domain.StatusActive {
t.Fatalf("状态=%s", got.Status)
}
adj, _ := db.AdjustmentsBySub(sub.ID)
if len(adj) != 2 || adj[0].Kind != "extend_days" || adj[0].DaysAdded != 30 {
t.Fatalf("追加记录不正确: %+v", adj[0])
}
if adj[0].BaseDate != domain.Date(today) {
t.Fatalf("记录起点=%s", adj[0].BaseDate)
}
}
func TestExtendDaysFromOldEndWhenActive(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
newEnd := domain.AddDays(today, 39)
plan, err := db.ExtendDays(sub.ID, domain.Date(newEnd), "", 0)
if err != nil {
t.Fatal(err)
}
if domain.Date(plan.Base) != domain.Date(domain.AddDays(today, 30)) {
t.Fatalf("未到期应从原结束日次日起算,实际 %s", domain.Date(plan.Base))
}
if plan.Days != 10 {
t.Fatalf("追加天数=%d want 10", plan.Days)
}
}
func TestExtendDaysRejectsBackwards(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
if _, err := db.ExtendDays(sub.ID, domain.Date(domain.AddDays(today, 10)), "", 0); err == nil {
t.Fatal("早于计算起点的日期应被拒绝")
}
}
func TestAddCountAndCheckinConsumption(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "深渊十次", domain.UnitCount, 10)
cid := mustCustomer(t, db, "5678", "大鹏")
today := domain.Today()
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), TotalCount: 10,
})
if err != nil {
t.Fatal(err)
}
if sub.TotalCount != 10 || sub.UsedCount != 0 {
t.Fatalf("total=%d used=%d", sub.TotalCount, sub.UsedCount)
}
// 打卡完成 → 扣 1 次
if err := db.SaveCheckin(sub.ID, domain.Date(today), "done", "", 0); err != nil {
t.Fatal(err)
}
got, _ := db.Sub(sub.ID)
if got.UsedCount != 1 {
t.Fatalf("used=%d want 1", got.UsedCount)
}
// 同一天改成「跳过」→ 返还
if err := db.SaveCheckin(sub.ID, domain.Date(today), "skipped", "", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 0 {
t.Fatalf("used=%d want 0", got.UsedCount)
}
// 再改回完成 → 再扣
if err := db.SaveCheckin(sub.ID, domain.Date(today), "done", "", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 1 {
t.Fatalf("used=%d want 1", got.UsedCount)
}
// 撤销打卡 → 返还
ci, err := db.CheckinFor(sub.ID, domain.Date(today))
if err != nil {
t.Fatal(err)
}
if _, err := db.DeleteCheckin(ci.ID, 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 0 {
t.Fatalf("used=%d want 0", got.UsedCount)
}
// 次数用尽后不能再打卡
_, _ = db.sql.Exec(`UPDATE subscriptions SET used_count=total_count WHERE id=?`, sub.ID)
if err := db.SaveCheckin(sub.ID, domain.Date(domain.AddDays(today, -1)), "done", "", 0); err == nil {
t.Fatal("次数用尽时打卡应被拒绝")
}
// 追加次数
if err := db.AddCount(sub.ID, 5, "", "加购", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.TotalCount != 15 {
t.Fatalf("total=%d want 15", got.TotalCount)
}
if err := db.SaveCheckin(sub.ID, domain.Date(domain.AddDays(today, -1)), "done", "", 0); err != nil {
t.Fatalf("追加后应可打卡: %v", err)
}
}
func TestPauseResumeShiftsEndDate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
if err := db.PauseSub(sub.ID, 0); err != nil {
t.Fatal(err)
}
got, _ := db.Sub(sub.ID)
if got.Status != domain.StatusPaused {
t.Fatalf("status=%s", got.Status)
}
// 把暂停开始时间改成 5 天前,模拟暂停了 5 天
oldStart := domain.Date(domain.AddDays(today, -5)) + " 09:00:00"
if _, err := db.sql.Exec(`UPDATE sub_pauses SET start_at=? WHERE sub_id=? AND end_at IS NULL`, oldStart, sub.ID); err != nil {
t.Fatal(err)
}
shift, err := db.ResumeSub(sub.ID, 0)
if err != nil {
t.Fatal(err)
}
if shift != 5 {
t.Fatalf("顺延天数=%d want 5", shift)
}
got, _ = db.Sub(sub.ID)
if got.EndDate != domain.Date(domain.AddDays(today, 34)) {
t.Fatalf("end=%s want %s", got.EndDate, domain.Date(domain.AddDays(today, 34)))
}
if got.Status != domain.StatusActive {
t.Fatalf("status=%s", got.Status)
}
}
func TestCalendarSubsExcludesOpenEndedCount(t *testing.T) {
db := newTestDB(t)
dayPkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cntPkg := mustPackage(t, db, "深渊十次", domain.UnitCount, 10)
today := domain.Today()
cid := mustCustomer(t, db, "1234", "老张")
daySub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: dayPkg.ID, StartDate: domain.Date(today), Days: 30,
})
cid2 := mustCustomer(t, db, "5678", "大鹏")
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid2, PackageID: cntPkg.ID, StartDate: domain.Date(today), TotalCount: 10,
}); err != nil {
t.Fatal(err)
}
subs, err := db.CalendarSubs(domain.Date(today), domain.Date(today))
if err != nil {
t.Fatal(err)
}
if len(subs) != 1 || subs[0].ID != daySub.ID {
t.Fatalf("无有效期的按次订阅不应出现在日历: %+v", subs)
}
}
func TestBackup(t *testing.T) {
db := newTestDB(t)
mustPackage(t, db, "包月日常", domain.UnitDay, 30)
path, err := db.BackupNow(14)
if err != nil {
t.Fatalf("备份失败: %v", err)
}
list, err := db.ListBackups()
if err != nil {
t.Fatal(err)
}
if len(list) != 1 {
t.Fatalf("备份数量=%d want 1 (%s)", len(list), path)
}
// 同日再备份:文件名按秒生成,允许重复
if _, err := db.BackupNow(1); err != nil {
// 同一秒内会重名,算作已存在,不视为失败
t.Logf("第二次备份返回: %v", err)
}
list, _ = db.ListBackups()
if len(list) > 1 {
t.Fatalf("保留份数未生效: %d", len(list))
}
}
func TestSettingsAndBadges(t *testing.T) {
db := newTestDB(t)
_ = db.SetSetting("warn_days", "5")
if got := db.Setting("warn_days", "3"); got != "5" {
t.Fatalf("setting=%s", got)
}
if got := db.Setting("missing", "def"); got != "def" {
t.Fatalf("默认值=%s", got)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
_, _ = db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.AddDays(today, -30)), Days: 31,
})
due, due3, err := db.Badges(today)
if err != nil {
t.Fatal(err)
}
if due != 1 || due3 != 1 {
t.Fatalf("due=%d due3=%d want 1/1", due, due3)
}
}
func TestSearchByTail(t *testing.T) {
db := newTestDB(t)
mustCustomer(t, db, "8899", "阿飞")
mustCustomer(t, db, "8890", "阿飞二号")
today := domain.Today()
list, total, err := db.ListCustomers("8899", 20, 0, domain.Date(today), 3)
if err != nil {
t.Fatal(err)
}
if total != 1 || len(list) != 1 || list[0].Name != "阿飞" {
t.Fatalf("搜索命中 %d 条", total)
}
list, total, _ = db.ListCustomers("889", 20, 0, domain.Date(today), 3)
if total != 2 {
t.Fatalf("前缀搜索应命中 2 条,实际 %d", total)
}
}
func TestUniqueCustomer(t *testing.T) {
db := newTestDB(t)
mustCustomer(t, db, "1234", "老张")
_, err := db.CreateCustomer(&Customer{Tail: "1234", Name: "老张"})
if err == nil {
t.Fatal("重复的尾号+姓名应被拒绝")
}
if _, err := db.CreateCustomer(&Customer{Tail: "1234", Name: "另一位"}); err != nil {
t.Fatalf("同尾号不同姓名应允许: %v", err)
}
}
var _ = time.Now
func TestScreenTopLimitedToTen(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
today := domain.Today()
for i := 0; i < 12; i++ {
cid, err := db.CreateCustomer(&Customer{Tail: "T" + strconv.Itoa(i), Name: "客户" + strconv.Itoa(i)})
if err != nil {
t.Fatalf("建客户失败: %v", err)
}
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30 - i,
}); err != nil {
t.Fatalf("建订阅失败: %v", err)
}
}
st, err := db.ScreenStats(today, 3)
if err != nil {
t.Fatalf("统计失败: %v", err)
}
if len(st.Top) != 10 {
t.Fatalf("TOP 榜应为 10 条,实际 %d 条", len(st.Top))
}
}
func TestBackupKeepPrune(t *testing.T) {
db := newTestDB(t)
mustPackage(t, db, "包月日常", domain.UnitDay, 30)
dir := db.BackupDir()
names := []string{
"daili-20260101-000001.db",
"daili-20260102-000001.db",
"daili-20260103-000001.db",
}
for _, n := range names {
if _, err := db.BackupTo(filepath.Join(dir, n), 0); err != nil {
t.Fatalf("备份失败: %v", err)
}
}
if err := db.pruneBackups(2); err != nil {
t.Fatalf("清理失败: %v", err)
}
list, err := db.ListBackups()
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("保留 2 份,实际 %d 份: %v", len(list), list)
}
if list[0] != names[2] || list[1] != names[1] {
t.Fatalf("保留的不是最新两份: %v", list)
}
}
func TestBackupDirOverride(t *testing.T) {
db := newTestDB(t)
custom := filepath.Join(t.TempDir(), "自定义备份")
db.SetBackupDir(custom)
if db.BackupDir() != custom {
t.Fatalf("备份目录未生效: %s", db.BackupDir())
}
if _, err := db.BackupNow(5); err != nil {
t.Fatalf("备份失败: %v", err)
}
if _, err := os.Stat(custom); err != nil {
t.Fatalf("备份目录未创建: %v", err)
}
}
// copyFile 用于模拟“用户只拷贝数据库文件”。
func copyFile(t *testing.T, src, dst string) {
t.Helper()
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("读取 %s 失败: %v", src, err)
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
t.Fatalf("写入 %s 失败: %v", dst, err)
}
}
func TestSingleFileDatabase(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "daili.db")
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
if mode, err := db.JournalMode(); err != nil || !strings.EqualFold(mode, "delete") {
t.Fatalf("journal_mode=%s err=%v", mode, err)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
}); err != nil {
t.Fatal(err)
}
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(path + suffix); err == nil {
t.Fatalf("运行中不应出现 %s", suffix)
}
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
if _, err := os.Stat(path + suffix); err == nil {
t.Fatalf("关闭后不应残留 %s", suffix)
}
}
if fi, err := os.Stat(path); err != nil || fi.Size() < 8192 {
t.Fatalf("主库大小异常: %v", err)
}
}
func TestCopyDatabaseIsLossless(t *testing.T) {
srcDir := t.TempDir()
srcPath := filepath.Join(srcDir, "daili.db")
db, err := Open(srcPath)
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
})
if err != nil {
t.Fatal(err)
}
if err := db.SaveCheckin(sub.ID, domain.Date(domain.Today()), "done", "迁移前打卡", 0); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
// 只拷贝 daili.db 一个文件
dstDir := t.TempDir()
dstPath := filepath.Join(dstDir, "daili.db")
copyFile(t, srcPath, dstPath)
cp, err := Open(dstPath)
if err != nil {
t.Fatalf("打开副本失败: %v", err)
}
defer cp.Close()
if n, err := cp.CustomerCount(); err != nil || n != 1 {
t.Fatalf("客户数=%d err=%v", n, err)
}
got, err := cp.Sub(sub.ID)
if err != nil || got.CustomerTail != "1234" || got.EndDate != sub.EndDate {
t.Fatalf("订阅丢失: %+v err=%v", got, err)
}
ci, err := cp.CheckinFor(sub.ID, domain.Date(domain.Today()))
if err != nil || ci.Note != "迁移前打卡" {
t.Fatalf("打卡记录丢失: %+v err=%v", ci, err)
}
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(dstPath + suffix); err == nil {
t.Fatalf("副本目录出现了 %s", suffix)
}
}
}
func TestDeleteDatabaseResets(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "daili.db")
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
mustCustomer(t, db, "1234", "老张")
if err := db.Close(); err != nil {
t.Fatal(err)
}
for _, p := range []string{path, path + "-wal", path + "-shm", path + "-journal"} {
_ = os.Remove(p)
}
fresh, err := Open(path)
if err != nil {
t.Fatalf("删除后重建失败: %v", err)
}
defer fresh.Close()
if err := fresh.Migrate(); err != nil {
t.Fatal(err)
}
if n, _ := fresh.CustomerCount(); n != 0 {
t.Fatalf("删库后应无客户,实际 %d", n)
}
if n, _ := fresh.CountUsers(); n != 0 {
t.Fatalf("删库后应无管理员,实际 %d", n)
}
}
func TestStaleSidecarsCleanup(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "daili.db")
// 模拟“删了主库但留下旧版本 WAL”的情况
if err := os.WriteFile(path+"-wal", []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path+"-shm", []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
db, err := Open(path)
if err != nil {
t.Fatalf("打开失败: %v", err)
}
defer db.Close()
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(path + suffix); err == nil {
t.Fatalf("残留的 %s 应被清理", suffix)
}
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
}
func TestOpenLegacyWALDatabaseKeepsData(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "legacy.db")
// 模拟 v1.1 的 WAL 库
dsn := strings.ReplaceAll(path, "\\", "/") + "?_pragma=journal_mode(WAL)"
old, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := old.Exec(`CREATE TABLE legacy(x INTEGER)`); err != nil {
t.Fatal(err)
}
if _, err := old.Exec(`INSERT INTO legacy VALUES(42)`); err != nil {
t.Fatal(err)
}
if err := old.Close(); err != nil {
t.Fatal(err)
}
db, err := Open(path)
if err != nil {
t.Fatalf("打开旧库失败: %v", err)
}
defer db.Close()
var x int
if err := db.sql.QueryRow(`SELECT x FROM legacy`).Scan(&x); err != nil || x != 42 {
t.Fatalf("旧库数据丢失: x=%d err=%v", x, err)
}
if mode, _ := db.JournalMode(); !strings.EqualFold(mode, "delete") {
t.Fatalf("旧库未切到单文件模式: %s", mode)
}
}
+597
View File
@@ -0,0 +1,597 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"daili/internal/domain"
)
// Subscription 服务订阅。
type Subscription struct {
ID int64
CustomerID int64
PackageID int64
Unit string
Status string
StartDate string
EndDate string
TotalCount int
UsedCount int
Note string
CreatedBy int64
CreatedAt string
UpdatedAt string
ClosedAt string
// 联表
PackageName string
PackageColor string
CustomerTail string
CustomerName string
CustomerContact string
}
// Eval 计算展示状态。
func (s *Subscription) Eval(today time.Time, warnDays int) domain.Eval {
return domain.EvalSub(s.Unit, s.Status, s.StartDate, s.EndDate, s.TotalCount, s.UsedCount, today, warnDays)
}
// IsCount 是否按次套餐。
func (s *Subscription) IsCount() bool { return s.Unit == domain.UnitCount }
// Pause 暂停分段。
type Pause struct {
ID int64
SubID int64
StartAt string
EndAt string
StartDate string
EndDate string
}
// Adjustment 追加记录。
type Adjustment struct {
ID int64
SubID int64
Kind string
BaseDate string
OldEndDate string
NewEndDate string
DaysAdded int
CountAdded int
OldTotal int
NewTotal int
Note string
OperatorID int64
OperatorName string
CreatedAt string
// 联表
CustomerTail string
CustomerName string
PackageName string
}
// KindLabel 返回追加类型的中文名。
func (a *Adjustment) KindLabel() string {
switch a.Kind {
case "initial":
return "开通"
case "extend_days":
return "追加天数"
case "add_count":
return "追加次数"
case "set_end_date":
return "调整结束日"
}
return "调整"
}
const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,s.status,s.start_date,IFNULL(s.end_date,''),
s.total_count,s.used_count,s.note,IFNULL(s.created_by,0),s.created_at,s.updated_at,IFNULL(s.closed_at,''),
p.name,p.color,c.tail,c.name,IFNULL(c.contact,'')
FROM subscriptions s
JOIN packages p ON p.id=s.package_id
JOIN customers c ON c.id=s.customer_id`
func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
var s Subscription
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate,
&s.TotalCount, &s.UsedCount, &s.Note, &s.CreatedBy, &s.CreatedAt, &s.UpdatedAt, &s.ClosedAt,
&s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &s, nil
}
// Sub 取单个订阅。
func (db *DB) Sub(id int64) (*Subscription, error) {
return scanSub(db.sql.QueryRow(subSelect+` WHERE s.id=?`, id))
}
// SubsByCustomer 取某客户的订阅。
func (db *DB) SubsByCustomer(customerID int64) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.customer_id=? ORDER BY s.status, s.id`, customerID)
}
// SubsByCustomers 批量取订阅(客户列表聚合用)。
func (db *DB) SubsByCustomers(ids []int64) ([]Subscription, error) {
if len(ids) == 0 {
return nil, nil
}
ph := placeholders(len(ids))
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
return db.querySubs(subSelect+` WHERE s.customer_id IN (`+ph+`) ORDER BY s.id`, args...)
}
func (db *DB) querySubs(q string, args ...any) ([]Subscription, error) {
rows, err := db.sql.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Subscription{}
for rows.Next() {
s, err := scanSub(rows)
if err != nil {
return nil, err
}
out = append(out, *s)
}
return out, rows.Err()
}
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
// SubFilter 订阅列表筛选。
type SubFilter struct {
Q string
State string
PackageID int64
Limit int
Offset int
Today time.Time
}
const expiredExpr = `((s.unit='day' AND s.end_date < ?) OR (s.unit='count' AND (s.used_count>=s.total_count OR (IFNULL(s.end_date,'')<>'' AND s.end_date < ?))))`
// ListSubs 分页查询订阅。
func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
where := []string{"1=1"}
args := []any{}
today := domain.Date(f.Today)
if q := strings.TrimSpace(f.Q); q != "" {
like := "%" + q + "%"
where = append(where, "(c.tail LIKE ? OR c.name LIKE ? OR p.name LIKE ?)")
args = append(args, like, like, like)
}
if f.PackageID > 0 {
where = append(where, "s.package_id=?")
args = append(args, f.PackageID)
}
switch f.State {
case "active":
where = append(where, "s.status='active'", "NOT "+expiredExpr)
args = append(args, today, today)
case "paused":
where = append(where, "s.status='paused'")
case "closed":
where = append(where, "s.status='closed'")
case "expired":
where = append(where, "s.status<>'closed'", expiredExpr)
args = append(args, today, today)
case "due7":
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
args = append(args, today, domain.Date(domain.AddDays(f.Today, 6)))
case "due3":
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
args = append(args, today, domain.Date(domain.AddDays(f.Today, 2)))
case "due_today":
where = append(where, "s.status<>'closed'", "s.end_date = ?")
args = append(args, today)
}
w := strings.Join(where, " AND ")
var total int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s
JOIN packages p ON p.id=s.package_id JOIN customers c ON c.id=s.customer_id WHERE `+w, args...).Scan(&total); err != nil {
return nil, 0, err
}
lim := f.Limit
if lim <= 0 {
lim = 30
}
q := subSelect + ` WHERE ` + w + ` ORDER BY s.status, IFNULL(s.end_date,'9999-12-31'), s.id DESC LIMIT ? OFFSET ?`
subs, err := db.querySubs(q, append(args, lim, f.Offset)...)
return subs, total, err
}
// CalendarSubs 取与 [from,to] 日期区间相交的订阅(用于日历)。
func (db *DB) CalendarSubs(from, to string) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.status<>'closed'
AND s.start_date <= ? AND IFNULL(s.end_date,'')<>'' AND s.end_date >= ?
ORDER BY s.id`, to, from)
}
// DueOn 某天到期的订阅。
func (db *DB) DueOn(date string) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date=? ORDER BY s.id`, date)
}
// CreateSubInput 新建订阅入参。
type CreateSubInput struct {
CustomerID int64
PackageID int64
StartDate string
EndDate string
Days int
TotalCount int
Note string
OperatorID int64
}
// CreateSub 新建订阅,并写入初始追加记录。
func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
pkg, err := db.Package(in.PackageID)
if err != nil {
return nil, errors.New("套餐不存在")
}
if pkg.Archived {
return nil, errors.New("该套餐已归档,不能新建订阅")
}
start, err := domain.ParseDate(in.StartDate)
if err != nil {
return nil, errors.New("开始日期格式不正确")
}
unit := pkg.Unit
endDate := ""
days := 0
total := 0
if unit == domain.UnitDay {
days = in.Days
if days <= 0 {
days = pkg.DefaultAmount
}
if days <= 0 {
days = 30
}
if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate)
if err != nil {
return nil, errors.New("结束日期格式不正确")
}
if domain.DayDiff(start, end) < 0 {
return nil, errors.New("结束日期不能早于开始日期")
}
endDate = domain.Date(end)
days = domain.DayDiff(start, end) + 1
} else {
endDate = domain.Date(domain.AddDays(start, days-1))
}
} else {
total = in.TotalCount
if total <= 0 {
total = pkg.DefaultAmount
}
if total <= 0 {
return nil, errors.New("总次数必须大于 0")
}
if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate)
if err != nil {
return nil, errors.New("有效期格式不正确")
}
if domain.DayDiff(start, end) < 0 {
return nil, errors.New("有效期不能早于开始日期")
}
endDate = domain.Date(end)
}
}
now := nowStamp()
var id int64
err = db.withTx(func(tx *sql.Tx) error {
var existing int64
err := tx.QueryRow(`SELECT id FROM subscriptions WHERE customer_id=? AND package_id=?`,
in.CustomerID, in.PackageID).Scan(&existing)
if err == nil {
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,note,created_by,created_at,updated_at)
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?)`,
in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now)
if err != nil {
if isUnique(err) {
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
}
return err
}
id, _ = res.LastInsertId()
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
id, "initial", domain.Date(start), "", nullIfEmpty(endDate), days, 0, 0, total, "新建订阅", nullIfZero(in.OperatorID), now)
return err
})
if err != nil {
return nil, err
}
return db.Sub(id)
}
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
// ExtendDays 按日追加:手动指定新的结束日,自动换算计算起点与追加天数。
func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64) (domain.ExtendPlan, error) {
var plan domain.ExtendPlan
today := domain.Today()
newEnd, err := domain.ParseDate(newEndDate)
if err != nil {
return plan, errors.New("请选择新的终止日期")
}
err = db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit != domain.UnitDay {
return errors.New("该订阅按次计费,请使用「追加次数」")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」")
}
oldEnd := domain.ParseDateOr(s.EndDate, today)
plan = domain.PlanExtend(oldEnd, newEnd, today)
if plan.Backwards {
return fmt.Errorf("所选日期早于计算起点 %s,不会增加时长", domain.Date(plan.Base))
}
status := s.Status
if status == domain.StatusPaused {
status = domain.StatusActive
}
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=?, status=?, updated_at=? WHERE id=?`,
domain.Date(newEnd), status, nowStamp(), subID); err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,0,0,0,?,?,?)`,
subID, "extend_days", domain.Date(plan.Base), s.EndDate, domain.Date(newEnd), plan.Days, note, nullIfZero(operatorID), nowStamp())
return err
})
return plan, err
}
// AddCount 按次追加次数(可同时调整有效期)。
func (db *DB) AddCount(subID int64, add int, newEndDate, note string, operatorID int64) error {
if add <= 0 {
return errors.New("追加次数必须大于 0")
}
err := db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit != domain.UnitCount {
return errors.New("该订阅按日计费,请使用「追加天数」")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」")
}
endDate := s.EndDate
if newEndDate != "" {
end, err := domain.ParseDate(newEndDate)
if err != nil {
return errors.New("有效期格式不正确")
}
if end.Before(domain.ParseDateOr(s.StartDate, domain.Today())) {
return errors.New("有效期不能早于开始日期")
}
endDate = domain.Date(end)
}
status := s.Status
if status == domain.StatusPaused {
status = domain.StatusActive
}
newTotal := s.TotalCount + add
if _, err := tx.Exec(`UPDATE subscriptions SET total_count=?, end_date=?, status=?, updated_at=? WHERE id=?`,
newTotal, nullIfEmpty(endDate), status, nowStamp(), subID); err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
VALUES(?,?,?,?,0,?,?,?,?,?,?)`,
subID, "add_count", s.EndDate, endDate, add, s.TotalCount, newTotal, note, nullIfZero(operatorID), nowStamp())
return err
})
return err
}
// PauseSub 暂停订阅。
func (db *DB) PauseSub(subID, operatorID int64) error {
return db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status == domain.StatusPaused {
return errors.New("订阅已处于暂停状态")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,无法暂停")
}
now := nowStamp()
if _, err := tx.Exec(`INSERT INTO sub_pauses(sub_id,start_at) VALUES(?,?)`, subID, now); err != nil {
return err
}
_, err = tx.Exec(`UPDATE subscriptions SET status='paused', updated_at=? WHERE id=?`, now, subID)
return err
})
}
// ResumeSub 恢复订阅,按日套餐顺延结束日,返回顺延天数。
func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
var shift int
today := domain.Today()
err := db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status != domain.StatusPaused {
return errors.New("订阅未处于暂停状态")
}
var startAt string
err = tx.QueryRow(`SELECT start_at FROM sub_pauses WHERE sub_id=? AND end_at IS NULL ORDER BY id DESC LIMIT 1`, subID).Scan(&startAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil {
ps := domain.ParseDateOr(startAt[:10], today)
shift = domain.DayDiff(ps, today)
if shift < 0 {
shift = 0
}
}
now := nowStamp()
if _, err := tx.Exec(`UPDATE sub_pauses SET end_at=? WHERE sub_id=? AND end_at IS NULL`, now, subID); err != nil {
return err
}
if s.Unit == domain.UnitDay && shift > 0 {
oldEnd := domain.ParseDateOr(s.EndDate, today)
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=? WHERE id=?`,
domain.Date(domain.AddDays(oldEnd, shift)), subID); err != nil {
return err
}
}
_, err = tx.Exec(`UPDATE subscriptions SET status='active', updated_at=? WHERE id=?`, now, subID)
return err
})
return shift, err
}
// CloseSub 结束订阅。
func (db *DB) CloseSub(subID int64) error {
now := nowStamp()
_, err := db.sql.Exec(`UPDATE subscriptions SET status='closed', closed_at=?, updated_at=? WHERE id=? AND status<>'closed'`,
now, now, subID)
return err
}
// ReopenSub 重新启用已结束的订阅。
func (db *DB) ReopenSub(subID int64) error {
_, err := db.sql.Exec(`UPDATE subscriptions SET status='active', closed_at=NULL, updated_at=? WHERE id=?`, nowStamp(), subID)
return err
}
// AdjustmentsBySub 取某订阅的追加记录(新→旧)。
func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name
FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id
JOIN subscriptions s ON s.id=a.sub_id
JOIN customers c ON c.id=s.customer_id
JOIN packages p ON p.id=s.package_id
WHERE a.sub_id=? ORDER BY a.id DESC`, subID)
}
// RecentAdjustments 最近追加记录。
func (db *DB) RecentAdjustments(limit int) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name
FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id
JOIN subscriptions s ON s.id=a.sub_id
JOIN customers c ON c.id=s.customer_id
JOIN packages p ON p.id=s.package_id
ORDER BY a.id DESC LIMIT ?`, limit)
}
func (db *DB) queryAdjustments(q string, args ...any) ([]Adjustment, error) {
rows, err := db.sql.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Adjustment{}
for rows.Next() {
var a Adjustment
if err := rows.Scan(&a.ID, &a.SubID, &a.Kind, &a.BaseDate, &a.OldEndDate, &a.NewEndDate,
&a.DaysAdded, &a.CountAdded, &a.OldTotal, &a.NewTotal, &a.Note, &a.OperatorID, &a.OperatorName, &a.CreatedAt,
&a.CustomerTail, &a.CustomerName, &a.PackageName); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// PausesForSubs 批量取暂停分段。
func (db *DB) PausesForSubs(ids []int64) (map[int64][]Pause, error) {
out := map[int64][]Pause{}
if len(ids) == 0 {
return out, nil
}
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
rows, err := db.sql.Query(`SELECT id,sub_id,start_at,IFNULL(end_at,'') FROM sub_pauses
WHERE sub_id IN (`+placeholders(len(ids))+`) ORDER BY id`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var p Pause
if err := rows.Scan(&p.ID, &p.SubID, &p.StartAt, &p.EndAt); err != nil {
return nil, err
}
if len(p.StartAt) >= 10 {
p.StartDate = p.StartAt[:10]
}
if len(p.EndAt) >= 10 {
p.EndDate = p.EndAt[:10]
}
out[p.SubID] = append(out[p.SubID], p)
}
return out, rows.Err()
}
// CountActiveSubs 活跃订阅数。
func (db *DB) CountActiveSubs(today string) (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active' AND (
(s.unit='day' AND s.end_date >= ?) OR
(s.unit='count' AND (IFNULL(s.end_date,'')='' OR s.end_date >= ?) AND s.used_count < s.total_count)
)`, today, today).Scan(&n)
return n, err
}
// SubTitle 生成「1234 老张 · 包月日常」式标题。
func (s *Subscription) SubTitle() string {
return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName)
}
+179
View File
@@ -0,0 +1,179 @@
package store
import (
"database/sql"
"errors"
"time"
"daili/internal/pw"
)
// User 管理员账户。
type User struct {
ID int64
Username string
DisplayName string
IsActive bool
CreatedAt string
LastLoginAt string
}
func scanUser(row interface {
Scan(dest ...any) error
}) (*User, error) {
var u User
var active int
var last sql.NullString
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.IsActive = active == 1
u.LastLoginAt = last.String
return &u, nil
}
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,'')`
// CountUsers 返回账户数量。
func (db *DB) CountUsers() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// CreateUser 新建账户。
func (db *DB) CreateUser(username, displayName, password string) (*User, error) {
hash, err := pw.Hash(password)
if err != nil {
return nil, err
}
now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, created_at)
VALUES(?,?,?,1,?)`, username, displayName, hash, now)
if err != nil {
if isUnique(err) {
return nil, ErrDuplicate
}
return nil, err
}
id, _ := res.LastInsertId()
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, CreatedAt: now}, nil
}
// Authenticate 校验用户名口令。
func (db *DB) Authenticate(username, password string) (*User, error) {
var hash string
var active int
var id int64
err := db.sql.QueryRow(`SELECT id, password_hash, is_active FROM users WHERE username=?`, username).
Scan(&id, &hash, &active)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if active != 1 || !pw.Verify(password, hash) {
return nil, ErrNotFound
}
_, _ = db.sql.Exec(`UPDATE users SET last_login_at=? WHERE id=?`, nowStamp(), id)
return db.UserByID(id)
}
// UserByID 按 ID 取账户。
func (db *DB) UserByID(id int64) (*User, error) {
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id))
}
// UserByUsername 按用户名取账户。
func (db *DB) UserByUsername(name string) (*User, error) {
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE username=?`, name))
}
// ListUsers 列出账户。
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.sql.Query(`SELECT ` + userCols + ` FROM users ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
// UpdatePassword 修改口令。
func (db *DB) UpdatePassword(userID int64, password string) error {
hash, err := pw.Hash(password)
if err != nil {
return err
}
_, err = db.sql.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID)
return err
}
// SetUserActive 启用/停用账户。
func (db *DB) SetUserActive(userID int64, active bool) error {
v := 0
if active {
v = 1
}
_, err := db.sql.Exec(`UPDATE users SET is_active=? WHERE id=?`, v, userID)
return err
}
// ---------- 会话 ----------
// CreateSession 新建登录会话。
func (db *DB) CreateSession(token string, userID int64, ttl time.Duration, ip string) error {
now := time.Now()
_, err := db.sql.Exec(`INSERT INTO sessions(token,user_id,created_at,expires_at,ip) VALUES(?,?,?,?,?)`,
token, userID, now.Format("2006-01-02 15:04:05"), now.Add(ttl).Format("2006-01-02 15:04:05"), ip)
return err
}
// SessionUser 通过令牌取账户,过期则返回 ErrNotFound。
func (db *DB) SessionUser(token string) (*User, error) {
var userID int64
var expires string
err := db.sql.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token=?`, token).Scan(&userID, &expires)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
exp, err := time.ParseInLocation("2006-01-02 15:04:05", expires, time.Local)
if err != nil || exp.Before(time.Now()) {
_ = db.DeleteSession(token)
return nil, ErrNotFound
}
u, err := db.UserByID(userID)
if err != nil {
return nil, err
}
if !u.IsActive {
return nil, ErrNotFound
}
return u, nil
}
// DeleteSession 删除会话。
func (db *DB) DeleteSession(token string) error {
_, err := db.sql.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err
}
// PurgeSessions 清理过期会话。
func (db *DB) PurgeSessions() {
_, _ = db.sql.Exec(`DELETE FROM sessions WHERE expires_at < ?`, nowStamp())
}
+119
View File
@@ -0,0 +1,119 @@
package web
import (
"net/http"
"strings"
"daili/internal/store"
)
func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
n, err := s.db.CountUsers()
if err != nil {
http.Error(w, "数据库异常", http.StatusInternalServerError)
return
}
if n > 0 {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
p := s.newPage(r, "初始化管理员", "")
s.render(w, r, "setup", p)
}
func (s *Server) handleSetupPost(w http.ResponseWriter, r *http.Request) {
n, _ := s.db.CountUsers()
if n > 0 {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if !sameOrigin(r) {
http.Error(w, "跨站请求被拒绝", http.StatusForbidden)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
display := strings.TrimSpace(r.PostFormValue("display_name"))
pw := r.PostFormValue("password")
pw2 := r.PostFormValue("password2")
p := s.newPage(r, "初始化管理员", "")
switch {
case len(username) < 3:
s.fail(w, r, "setup", "用户名至少 3 个字符", p)
return
case len(pw) < 6:
s.fail(w, r, "setup", "密码至少 6 位", p)
return
case pw != pw2:
s.fail(w, r, "setup", "两次输入的密码不一致", p)
return
}
if display == "" {
display = username
}
u, err := s.db.CreateUser(username, display, pw)
if err != nil {
s.fail(w, r, "setup", "创建失败:"+err.Error(), p)
return
}
_ = s.db.Log(store.OpLog{OperatorID: u.ID, OperatorName: u.DisplayName, Action: "创建管理员",
Entity: "user", EntityID: u.ID, Summary: "初始化管理员账号 " + username, IP: clientIP(r)})
s.startSession(w, r, u)
s.redirect(w, r, "/packages", "管理员已创建,先添加一个服务套餐吧")
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
n, _ := s.db.CountUsers()
if n == 0 {
http.Redirect(w, r, "/setup", http.StatusFound)
return
}
if s.userFromRequest(r) != nil {
http.Redirect(w, r, "/calendar", http.StatusFound)
return
}
p := s.newPage(r, "登录", "")
s.render(w, r, "login", p)
}
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if !sameOrigin(r) {
http.Error(w, "跨站请求被拒绝", http.StatusForbidden)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
pw := r.PostFormValue("password")
next := r.PostFormValue("next")
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
next = "/calendar"
}
u, err := s.db.Authenticate(username, pw)
if err != nil {
p := s.newPage(r, "登录", "")
s.fail(w, r, "login", "用户名或密码不正确", p)
return
}
_ = s.db.Log(store.OpLog{OperatorID: u.ID, OperatorName: u.DisplayName, Action: "登录",
Entity: "session", Summary: "登录成功", IP: clientIP(r)})
s.startSession(w, r, u)
s.redirect(w, r, next, "欢迎回来,"+u.DisplayName)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieSession); err == nil && c.Value != "" {
_ = s.db.DeleteSession(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: cookieSession, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// logOp 是写操作日志的便捷方法。
func (s *Server) logOp(r *http.Request, action, entity string, entityID int64, summary string) {
u := s.currentUser(r)
if u == nil {
return
}
_ = s.db.Log(store.OpLog{
OperatorID: u.ID, OperatorName: u.DisplayName, Action: action,
Entity: entity, EntityID: entityID, Summary: summary, IP: clientIP(r),
})
}
+348
View File
@@ -0,0 +1,348 @@
package web
import (
"fmt"
"net/http"
"sort"
"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
Heat 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
}
// TaskRow 是当日待打清单的一行。
type TaskRow struct {
SubView
CheckinID int64
Done bool
Skipped bool
Note string
Operator string
}
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
Due []SubView
OtherDue []SubView
Extend *ExtendPanel
MonthDue int
MonthTasks int
MonthDone int
NoPackages bool
NoCustomers bool
Back string
}
// calSub 是日历计算用的订阅上下文。
type calSub struct {
sub store.Subscription
start time.Time
end time.Time
hasEnd bool
eval domain.Eval
pauses []store.Pause
}
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
}
if c.sub.Unit == domain.UnitCount && c.eval.RemainingCount <= 0 {
return false
}
return !c.pausedOn(date)
}
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))
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, err := s.db.CalendarSubs(domain.Date(gridStart), domain.Date(gridEnd))
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, err := s.db.PausesForSubs(ids)
if err != nil {
http.Error(w, "读取暂停记录失败", http.StatusInternalServerError)
return
}
checkins, err := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(gridEnd))
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),
eval: sub.Eval(today, warn),
pauses: pauses[sub.ID],
}
if sub.EndDate != "" {
cs.end = domain.ParseDateOr(sub.EndDate, today)
cs.hasEnd = true
}
list = append(list, cs)
}
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: r.URL.RequestURI(),
}
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
}
label := selT.Format("1 月 2 日")
data.SelectedLabel = label + " 周" + 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 || !cs.covers(ds) {
continue
}
cell.TaskCount++
ci := checkins[cs.sub.ID][ds]
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,
}
if t.Done || t.Skipped {
cell.DoneCount++
}
tickets = append(tickets, t)
}
sort.SliceStable(tickets, func(i, j int) bool {
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.Heat = heatLevel(cell.TaskCount + cell.DueCount)
if cell.InMonth {
data.MonthDue += cell.DueCount
data.MonthTasks += cell.TaskCount
data.MonthDone += cell.DoneCount
}
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
}
v := newSubView(cs.sub, today, warn)
row := TaskRow{SubView: v}
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)
}
sort.SliceStable(tasks, func(i, j int) bool {
ai := tasks[i].Done || tasks[i].Skipped
aj := tasks[j].Done || tasks[j].Skipped
if ai != aj {
return !ai
}
return tasks[i].EndDate < tasks[j].EndDate
})
data.Tasks = tasks
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
}
data.OtherDue = append(data.OtherDue, newSubView(u, today, warn))
}
}
// 追加面板
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 {
back := r.URL.Path + "?" + r.URL.RawQuery
data.Extend = buildExtendPanel(sub, pkg, today, back, warn)
}
}
}
// 空态提示
if n, _ := s.db.CustomerCount(); n == 0 {
data.NoCustomers = true
}
if pkgs, _ := s.db.ListPackages(false); len(pkgs) == 0 {
data.NoPackages = true
}
p := s.newPage(r, "日历", "calendar")
p.D = data
s.render(w, r, "calendar", p)
}
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])
}
+305
View File
@@ -0,0 +1,305 @@
package web
import (
"errors"
"net/http"
"sort"
"strconv"
"strings"
"daili/internal/domain"
"daili/internal/store"
)
type customersData struct {
List []store.Customer
Pager Pager
Q string
Total int
PackageCount int
NoPackages bool
}
func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
warn := s.warnDays()
q := r.URL.Query().Get("q")
pageNo := s.queryInt(r, "page", 1)
if pageNo < 1 {
pageNo = 1
}
list, total, err := s.db.ListCustomers(q, pageSize, (pageNo-1)*pageSize, domain.Date(today), warn)
if err != nil {
http.Error(w, "读取客户失败", http.StatusInternalServerError)
return
}
pager := newPager(r, pageNo, pageSize, total)
if pager.Page != pageNo {
list, total, err = s.db.ListCustomers(q, pageSize, (pager.Page-1)*pageSize, domain.Date(today), warn)
if err != nil {
http.Error(w, "读取客户失败", http.StatusInternalServerError)
return
}
}
pkgs, _ := s.db.ListPackages(false)
data := &customersData{List: list, Pager: pager, Q: q, Total: total, PackageCount: len(pkgs), NoPackages: len(pkgs) == 0}
p := s.newPage(r, "客户", "customers")
p.D = data
s.render(w, r, "customers", p)
}
type customerFormData struct {
Customer *store.Customer
IsNew bool
}
func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新建客户", "customers")
p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true}
s.render(w, r, "customer_form", p)
}
func readCustomerForm(r *http.Request) *store.Customer {
return &store.Customer{
Tail: strings.TrimSpace(r.PostFormValue("tail")),
Name: strings.TrimSpace(r.PostFormValue("name")),
GameName: strings.TrimSpace(r.PostFormValue("game_name")),
GameServer: strings.TrimSpace(r.PostFormValue("game_server")),
Contact: strings.TrimSpace(r.PostFormValue("contact")),
Tags: normalizeTags(r.PostFormValue("tags")),
Source: strings.TrimSpace(r.PostFormValue("source")),
Note: strings.TrimSpace(r.PostFormValue("note")),
}
}
func normalizeTags(v string) string {
parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ',' || r == ' ' || r == ';' || r == ';' })
seen := map[string]bool{}
out := []string{}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" || seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
return strings.Join(out, ",")
}
func validateCustomer(c *store.Customer) string {
if c.Tail == "" {
return "手机尾号不能为空"
}
if len([]rune(c.Tail)) > 8 {
return "手机尾号最长 8 位"
}
if c.Name == "" {
return "客户名不能为空"
}
if len([]rune(c.Name)) > 32 {
return "客户名最长 32 字"
}
return ""
}
func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
c := readCustomerForm(r)
p := s.newPage(r, "新建客户", "customers")
if msg := validateCustomer(c); msg != "" {
p.D = &customerFormData{Customer: c, IsNew: true}
s.fail(w, r, "customer_form", msg, p)
return
}
id, err := s.db.CreateCustomer(c)
if err != nil {
msg := "创建失败:" + err.Error()
if errors.Is(err, store.ErrDuplicate) {
msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅"
}
p.D = &customerFormData{Customer: c, IsNew: true}
s.fail(w, r, "customer_form", msg, p)
return
}
s.logOp(r, "新建客户", "customer", id, "新建客户 "+c.Tail+" "+c.Name)
s.redirect(w, r, "/customers/"+itoa(id), "客户已创建,接着为他添加订阅")
}
func (s *Server) handleCustomerEditPage(w http.ResponseWriter, r *http.Request) {
c, err := s.db.Customer(pathID(r))
if err != nil {
http.NotFound(w, r)
return
}
p := s.newPage(r, "编辑客户", "customers")
p.D = &customerFormData{Customer: c, IsNew: false}
s.render(w, r, "customer_form", p)
}
func (s *Server) handleCustomerUpdate(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
old, err := s.db.Customer(id)
if err != nil {
http.NotFound(w, r)
return
}
c := readCustomerForm(r)
c.ID = id
p := s.newPage(r, "编辑客户", "customers")
if msg := validateCustomer(c); msg != "" {
p.D = &customerFormData{Customer: c, IsNew: false}
s.fail(w, r, "customer_form", msg, p)
return
}
if err := s.db.UpdateCustomer(c); err != nil {
msg := "保存失败:" + err.Error()
if errors.Is(err, store.ErrDuplicate) {
msg = "该「尾号 + 姓名」的客户已存在"
}
p.D = &customerFormData{Customer: c, IsNew: false}
s.fail(w, r, "customer_form", msg, p)
return
}
s.logOp(r, "编辑客户", "customer", id, "修改客户 "+c.Tail+" "+c.Name+"(原 "+old.Tail+" "+old.Name+")")
s.redirect(w, r, "/customers/"+itoa(id), "客户资料已保存")
}
// CheckinRow 是打卡历史的展示行。
type CheckinRow struct {
store.Checkin
SubName string
SubColor string
}
type customerDetailData struct {
Customer *store.Customer
Subs []SubView
Adjustments []store.Adjustment
AdjSubName map[int64]string
AdjSubColor map[int64]string
Checkins []CheckinRow
Packages []store.Package
Timeline *Timeline
Extend *ExtendPanel
TotalRemain string
}
func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
c, err := s.db.Customer(id)
if err != nil {
http.NotFound(w, r)
return
}
today := domain.Today()
warn := s.warnDays()
subs, err := s.db.SubsByCustomer(id)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
pkgs, _ := s.db.ListPackages(false)
ids := make([]int64, 0, len(subs))
subByID := map[int64]store.Subscription{}
for _, sub := range subs {
ids = append(ids, sub.ID)
subByID[sub.ID] = sub
}
pauses, _ := s.db.PausesForSubs(ids)
data := &customerDetailData{
Customer: c,
Subs: subViews(subs, today, warn),
Packages: pkgs,
Timeline: buildTimeline(subs, pauses, today),
AdjSubName: map[int64]string{},
AdjSubColor: map[int64]string{},
}
for _, sub := range subs {
data.AdjSubName[sub.ID] = sub.PackageName
data.AdjSubColor[sub.ID] = sub.PackageColor
adj, err := s.db.AdjustmentsBySub(sub.ID)
if err == nil {
data.Adjustments = append(data.Adjustments, adj...)
}
if ci, err := s.db.CheckinsForSub(sub.ID, 30); err == nil {
for _, one := range ci {
data.Checkins = append(data.Checkins, CheckinRow{Checkin: one, SubName: sub.PackageName, SubColor: sub.PackageColor})
}
}
}
// 按时间排序(新→旧)
sortAdjustments(data.Adjustments)
sortCheckins(data.Checkins)
if eid := s.queryInt(r, "extend", 0); eid > 0 {
if sub, ok := subByID[int64(eid)]; ok {
if pkg, err := s.db.Package(sub.PackageID); err == nil {
back := r.URL.Path + "?" + r.URL.RawQuery
data.Extend = buildExtendPanel(&sub, pkg, today, back, warn)
}
}
}
p := s.newPage(r, c.Tail+" "+c.Name, "customers")
p.D = data
s.render(w, r, "customer_detail", p)
}
func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
customerID := pathID(r)
c, err := s.db.Customer(customerID)
if err != nil {
http.NotFound(w, r)
return
}
in := store.CreateSubInput{
CustomerID: customerID,
PackageID: int64(s.formInt(r, "package_id", 0)),
StartDate: r.PostFormValue("start_date"),
EndDate: r.PostFormValue("end_date"),
Days: s.formInt(r, "days", 0),
TotalCount: s.formInt(r, "total_count", 0),
Note: strings.TrimSpace(r.PostFormValue("note")),
}
u := s.currentUser(r)
if u != nil {
in.OperatorID = u.ID
}
sub, err := s.db.CreateSub(in)
if err != nil {
s.redirect(w, r, "/customers/"+itoa(customerID), "创建订阅失败:"+err.Error())
return
}
s.logOp(r, "新建订阅", "subscription", sub.ID,
"为 "+c.Tail+" "+c.Name+" 开通 "+sub.PackageName+"("+unitLabel(sub.Unit)+")")
s.redirect(w, r, "/customers/"+itoa(customerID), "已开通「"+sub.PackageName+"」")
}
func unitLabel(u string) string {
if u == domain.UnitCount {
return "按次"
}
return "按日"
}
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
func sortAdjustments(list []store.Adjustment) {
sort.SliceStable(list, func(i, j int) bool {
if list[i].CreatedAt != list[j].CreatedAt {
return list[i].CreatedAt > list[j].CreatedAt
}
return list[i].ID > list[j].ID
})
}
func sortCheckins(list []CheckinRow) {
sort.SliceStable(list, func(i, j int) bool {
if list[i].ServiceDate != list[j].ServiceDate {
return list[i].ServiceDate > list[j].ServiceDate
}
return list[i].ID > list[j].ID
})
}
+299
View File
@@ -0,0 +1,299 @@
package web
import (
"encoding/csv"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
"daili/internal/domain"
"daili/internal/store"
)
type logsData struct {
List []store.OpLog
Pager Pager
Q string
Total int
}
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
pageNo := s.queryInt(r, "page", 1)
if pageNo < 1 {
pageNo = 1
}
list, total, err := s.db.ListLogs(q, pageSize, (pageNo-1)*pageSize)
if err != nil {
http.Error(w, "读取日志失败", http.StatusInternalServerError)
return
}
pager := newPager(r, pageNo, pageSize, total)
if pager.Page != pageNo {
list, total, err = s.db.ListLogs(q, pageSize, (pager.Page-1)*pageSize)
if err != nil {
http.Error(w, "读取日志失败", http.StatusInternalServerError)
return
}
}
p := s.newPage(r, "操作日志", "logs")
p.D = &logsData{List: list, Pager: pager, Q: q, Total: total}
s.render(w, r, "logs", p)
}
type settingsData struct {
WarnDays int
ScreenRefresh int
Users []store.User
Backups []string
DBPath string
BackupDir string
BackupKeep int
BackupEnabled bool
ConfigPath string
ScreenPublic bool
Addrs []string
Port string
Uptime string
LogCount int
Version string
CustomerCount int
PackageCount int
}
func humanDuration(d time.Duration) string {
secs := int(d.Seconds())
days := secs / 86400
hours := (secs % 86400) / 3600
mins := (secs % 3600) / 60
switch {
case days > 0:
return fmt.Sprintf("%d 天 %d 小时", days, hours)
case hours > 0:
return fmt.Sprintf("%d 小时 %d 分钟", hours, mins)
default:
return fmt.Sprintf("%d 分钟", mins)
}
}
func LocalAddrs() []string {
out := []string{}
ifaces, err := net.Interfaces()
if err != nil {
return out
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
var ip net.IP
switch v := a.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil {
continue
}
ip4 := ip.To4()
if ip4 == nil || ip4.IsLoopback() {
continue
}
ipStr := ip4.String()
if strings.HasPrefix(ipStr, "169.254.") {
continue
}
out = append(out, ipStr)
}
}
return out
}
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
users, _ := s.db.ListUsers()
backups, _ := s.db.ListBackups()
custN, _ := s.db.CustomerCount()
pkgs, _ := s.db.ListPackages(true)
logN, _ := s.db.LogCount()
port := s.cfg.Addr
if strings.HasPrefix(port, ":") {
port = port[1:]
}
data := &settingsData{
WarnDays: s.warnDays(),
ScreenRefresh: s.screenRefresh(),
Users: users,
Backups: backups,
DBPath: s.db.Path,
BackupDir: s.cfg.BackupDir,
BackupKeep: s.cfg.BackupKeep,
BackupEnabled: s.cfg.BackupEnabled,
ConfigPath: s.cfg.ConfigPath,
ScreenPublic: s.cfg.ScreenPublic,
Addrs: LocalAddrs(),
Port: port,
Uptime: humanDuration(time.Since(s.started)),
LogCount: logN,
Version: s.cfg.Version,
CustomerCount: custN,
PackageCount: len(pkgs),
}
p := s.newPage(r, "设置", "settings")
p.D = data
s.render(w, r, "settings", p)
}
func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
warn := s.formInt(r, "warn_days", 3)
if warn < 0 {
warn = 0
}
if warn > 60 {
warn = 60
}
refresh := s.formInt(r, "screen_refresh", 30)
if refresh < 5 {
refresh = 5
}
if refresh > 600 {
refresh = 600
}
_ = s.db.SetSetting("warn_days", strconv.Itoa(warn))
_ = s.db.SetSetting("screen_refresh", strconv.Itoa(refresh))
s.logOp(r, "修改设置", "settings", 0,
fmt.Sprintf("预警 %d 天、大屏刷新 %d 秒", warn, refresh))
s.redirect(w, r, "/settings", "设置已保存")
}
func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
old := r.PostFormValue("old_password")
pw := r.PostFormValue("password")
pw2 := r.PostFormValue("password2")
if u == nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if _, err := s.db.Authenticate(u.Username, old); err != nil {
s.redirect(w, r, "/settings", "原密码不正确")
return
}
if len(pw) < 6 {
s.redirect(w, r, "/settings", "新密码至少 6 位")
return
}
if pw != pw2 {
s.redirect(w, r, "/settings", "两次输入的新密码不一致")
return
}
if err := s.db.UpdatePassword(u.ID, pw); err != nil {
s.redirect(w, r, "/settings", "修改失败:"+err.Error())
return
}
s.logOp(r, "修改密码", "user", u.ID, "管理员 "+u.Username+" 修改了自己的密码")
s.redirect(w, r, "/settings", "密码已更新")
}
func (s *Server) handleUserCreate(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PostFormValue("username"))
display := strings.TrimSpace(r.PostFormValue("display_name"))
pw := r.PostFormValue("password")
switch {
case len(username) < 3:
s.redirect(w, r, "/settings", "用户名至少 3 个字符")
return
case len(pw) < 6:
s.redirect(w, r, "/settings", "密码至少 6 位")
return
}
if display == "" {
display = username
}
u, err := s.db.CreateUser(username, display, pw)
if err != nil {
if err == store.ErrDuplicate {
s.redirect(w, r, "/settings", "用户名已存在")
return
}
s.redirect(w, r, "/settings", "创建失败:"+err.Error())
return
}
s.logOp(r, "新增管理员", "user", u.ID, "新增管理员 "+username)
s.redirect(w, r, "/settings", "管理员 "+username+" 已创建")
}
func (s *Server) handleUserToggle(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
me := s.currentUser(r)
if me != nil && me.ID == id {
s.redirect(w, r, "/settings", "不能停用自己的账户")
return
}
target, err := s.db.UserByID(id)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.db.SetUserActive(id, !target.IsActive); err != nil {
s.redirect(w, r, "/settings", "操作失败:"+err.Error())
return
}
action := "停用账户"
if !target.IsActive {
action = "启用账户"
}
s.logOp(r, action, "user", id, action+" "+target.Username)
s.redirect(w, r, "/settings", action+"成功")
}
func (s *Server) handleBackupNow(w http.ResponseWriter, r *http.Request) {
path, err := s.db.BackupNow(s.cfg.BackupKeep)
if err != nil {
s.redirect(w, r, "/settings", "备份失败:"+err.Error())
return
}
s.logOp(r, "备份数据", "backup", 0, "手动备份 "+path)
s.redirect(w, r, "/settings", "已备份:"+path)
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
kind := strings.TrimSuffix(r.PathValue("kind"), ".csv")
var set *store.ExportRowSet
var err error
switch kind {
case "customers":
set, err = s.db.ExportCustomers()
case "subscriptions":
set, err = s.db.ExportSubscriptions(domain.Today(), s.warnDays())
case "logs":
set, err = s.db.ExportLogs()
default:
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "导出失败:"+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=\"daili-%s-%s.csv\"", kind, time.Now().Format("20060102")))
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
cw := csv.NewWriter(w)
_ = cw.Write(set.Header)
for _, row := range set.Rows {
_ = cw.Write(row)
}
cw.Flush()
s.logOp(r, "导出数据", "export", 0, "导出 "+kind+"("+strconv.Itoa(len(set.Rows))+" 行)")
}
+156
View File
@@ -0,0 +1,156 @@
package web
import (
"net/http"
"strconv"
"strings"
"daili/internal/domain"
"daili/internal/store"
)
type packagesData struct {
List []store.Package
}
func (s *Server) handlePackages(w http.ResponseWriter, r *http.Request) {
list, err := s.db.ListPackages(true)
if err != nil {
http.Error(w, "读取套餐失败", http.StatusInternalServerError)
return
}
p := s.newPage(r, "套餐", "packages")
p.D = &packagesData{List: list}
s.render(w, r, "packages", p)
}
type packageFormData struct {
Package *store.Package
IsNew bool
}
func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新增套餐", "packages")
p.D = &packageFormData{
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#2F5D50"},
IsNew: true,
}
s.render(w, r, "package_form", p)
}
func readPackageForm(r *http.Request) *store.Package {
p := &store.Package{
Name: strings.TrimSpace(r.PostFormValue("name")),
Unit: strings.TrimSpace(r.PostFormValue("unit")),
DefaultAmount: 0,
Color: strings.TrimSpace(r.PostFormValue("color")),
Note: strings.TrimSpace(r.PostFormValue("note")),
SortOrder: 0,
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("default_amount"))); err == nil {
p.DefaultAmount = v
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("sort_order"))); err == nil {
p.SortOrder = v
}
if price := strings.TrimSpace(r.PostFormValue("price")); price != "" {
if f, err := strconv.ParseFloat(price, 64); err == nil {
p.PriceCents = int(f*100 + 0.5)
p.HasPrice = true
}
}
if p.Unit == "" {
p.Unit = domain.UnitDay
}
return p
}
func validatePackage(p *store.Package) string {
if p.Name == "" {
return "套餐名称不能为空"
}
if len([]rune(p.Name)) > 32 {
return "套餐名称最长 32 字"
}
if p.Unit != domain.UnitDay && p.Unit != domain.UnitCount {
return "套餐类型不正确"
}
if p.DefaultAmount <= 0 {
return "默认天数/次数必须大于 0"
}
if p.Color == "" || !strings.HasPrefix(p.Color, "#") {
return "请选择套餐颜色"
}
return ""
}
func (s *Server) handlePackageCreate(w http.ResponseWriter, r *http.Request) {
pkg := readPackageForm(r)
p := s.newPage(r, "新增套餐", "packages")
if msg := validatePackage(pkg); msg != "" {
p.D = &packageFormData{Package: pkg, IsNew: true}
s.fail(w, r, "package_form", msg, p)
return
}
id, err := s.db.CreatePackage(pkg)
if err != nil {
p.D = &packageFormData{Package: pkg, IsNew: true}
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
return
}
s.logOp(r, "新增套餐", "package", id, "新增套餐 "+pkg.Name)
s.redirect(w, r, "/packages", "套餐已创建,现在可以给客户开通了")
}
func (s *Server) handlePackageEditPage(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
p := s.newPage(r, "编辑套餐", "packages")
p.D = &packageFormData{Package: pkg, IsNew: false}
s.render(w, r, "package_form", p)
}
func (s *Server) handlePackageUpdate(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg := readPackageForm(r)
pkg.ID = id
p := s.newPage(r, "编辑套餐", "packages")
if msg := validatePackage(pkg); msg != "" {
p.D = &packageFormData{Package: pkg, IsNew: false}
s.fail(w, r, "package_form", msg, p)
return
}
if err := s.db.UpdatePackage(pkg); err != nil {
p.D = &packageFormData{Package: pkg, IsNew: false}
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
return
}
s.logOp(r, "编辑套餐", "package", id, "修改套餐 "+pkg.Name)
s.redirect(w, r, "/packages", "套餐已保存")
}
func (s *Server) handlePackageArchive(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
archived := !pkg.Archived
if err := s.db.SetPackageArchived(id, archived); err != nil {
s.redirect(w, r, "/packages", "操作失败:"+err.Error())
return
}
action := "归档套餐"
msg := "套餐已归档,不再允许新订阅(已有订阅不受影响)"
if !archived {
action = "取消归档"
msg = "套餐已恢复"
}
s.logOp(r, action, "package", id, action+" "+pkg.Name)
s.redirect(w, r, "/packages", msg)
}
+134
View File
@@ -0,0 +1,134 @@
package web
import (
"fmt"
"net/http"
"time"
"daili/internal/domain"
"daili/internal/store"
)
type screenDay struct {
Date string
Day int
InMonth bool
IsToday bool
IsPast bool
Due int
Heat int
}
type screenData struct {
Stats *store.Stats
MonthTitle string
Year, Month int
Weeks [][]screenDay
Weekdays []string
Top []SubView
DueToday []SubView
TodayList []SubView
Recent []store.Adjustment
TodayTasks int
TodayDone int
Refresh int
Version string
}
func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
warn := s.warnDays()
stats, err := s.db.ScreenStats(today, warn)
if err != nil {
http.Error(w, "读取大屏数据失败", http.StatusInternalServerError)
return
}
data := &screenData{
Stats: stats,
MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())),
Year: today.Year(),
Month: int(today.Month()),
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
Top: subViews(stats.Top, today, warn),
DueToday: subViews(stats.DueTodaySubs, today, warn),
Refresh: s.screenRefresh(),
Version: s.cfg.Version,
}
if len(data.DueToday) > 6 {
data.DueToday = data.DueToday[:6]
}
// 本月热力
monthStart := domain.MonthStart(today.Year(), today.Month())
monthEnd := domain.MonthEnd(today.Year(), today.Month())
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
for wi := 0; wi < weeks; wi++ {
week := make([]screenDay, 0, 7)
for di := 0; di < 7; di++ {
d := domain.AddDays(gridStart, wi*7+di)
ds := domain.Date(d)
due := stats.Heat[ds]
week = append(week, screenDay{
Date: ds,
Day: d.Day(),
InMonth: d.Month() == today.Month(),
IsToday: ds == domain.Date(today),
IsPast: d.Before(today),
Due: due,
Heat: heatLevel(due),
})
}
data.Weeks = append(data.Weeks, week)
}
if adj, err := s.db.RecentAdjustments(6); err == nil {
data.Recent = adj
}
list, done := s.todayTaskList(today)
data.TodayTasks = len(list)
data.TodayDone = done
data.TodayList = subViews(list, today, warn)
p := s.newPage(r, "大屏", "screen")
p.D = data
s.render(w, r, "screen", p)
}
// todayTaskList 返回今天应服务的订阅与已完成数量。
func (s *Server) todayTaskList(today time.Time) ([]store.Subscription, int) {
ts := domain.Date(today)
subs, err := s.db.CalendarSubs(ts, ts)
if err != nil {
return nil, 0
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, _ := s.db.PausesForSubs(ids)
checkins, _ := s.db.CheckinsBetween(ts, ts)
warn := s.warnDays()
out := []store.Subscription{}
done := 0
for _, sub := range subs {
if sub.Status == domain.StatusClosed {
continue
}
if sub.StartDate > ts || sub.EndDate == "" || sub.EndDate < ts {
continue
}
cs := calSub{sub: sub, pauses: pauses[sub.ID], hasEnd: true, eval: sub.Eval(today, warn)}
if sub.Unit == domain.UnitCount && cs.eval.RemainingCount <= 0 {
continue
}
if cs.pausedOn(ts) {
continue
}
out = append(out, sub)
if ci, ok := checkins[sub.ID][ts]; ok && ci.Status == "done" {
done++
}
}
return out, done
}
+185
View File
@@ -0,0 +1,185 @@
package web
import (
"fmt"
"net/http"
"strings"
"daili/internal/domain"
"daili/internal/store"
)
type subsData struct {
List []SubView
Pager Pager
State string
PackageID int64
Q string
Packages []store.Package
Total int
}
func (s *Server) handleSubscriptions(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
warn := s.warnDays()
state := r.URL.Query().Get("state")
if state == "" {
state = "active"
}
packageID := int64(s.queryInt(r, "package_id", 0))
q := r.URL.Query().Get("q")
pageNo := s.queryInt(r, "page", 1)
if pageNo < 1 {
pageNo = 1
}
f := store.SubFilter{Q: q, State: state, PackageID: packageID, Limit: pageSize, Offset: (pageNo - 1) * pageSize, Today: today}
list, total, err := s.db.ListSubs(f)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
pager := newPager(r, pageNo, pageSize, total)
if pager.Page != pageNo {
f.Offset = (pager.Page - 1) * pageSize
list, total, err = s.db.ListSubs(f)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
}
pkgs, _ := s.db.ListPackages(true)
data := &subsData{
List: subViews(list, today, warn), Pager: pager, State: state,
PackageID: packageID, Q: q, Packages: pkgs, Total: total,
}
p := s.newPage(r, "订阅", "subscriptions")
p.D = data
s.render(w, r, "subscriptions", p)
}
func (s *Server) handleSubExtend(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
u := s.currentUser(r)
var opID int64
if u != nil {
opID = u.ID
}
note := strings.TrimSpace(r.PostFormValue("note"))
back := backURL(r, "/customers/"+itoa(sub.CustomerID))
if sub.Unit == domain.UnitDay {
newEnd := strings.TrimSpace(r.PostFormValue("new_end_date"))
plan, err := s.db.ExtendDays(id, newEnd, note, opID)
if err != nil {
s.redirect(w, r, back+"#extend", "追加失败:"+err.Error())
return
}
s.logOp(r, "追加时长", "subscription", id, fmt.Sprintf("%s 追加 %d 天:%s → %s(起点 %s)",
sub.SubTitle(), plan.Days, sub.EndDate, domain.Date(plan.NewEnd), domain.Date(plan.Base)))
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 天,新结束日 %s", plan.Days, domain.Date(plan.NewEnd)))
return
}
add := s.formInt(r, "add_count", 0)
optEnd := strings.TrimSpace(r.PostFormValue("new_end_opt"))
if err := s.db.AddCount(id, add, optEnd, note, opID); err != nil {
s.redirect(w, r, back+"#extend", "追加失败:"+err.Error())
return
}
s.logOp(r, "追加次数", "subscription", id, fmt.Sprintf("%s 追加 %d 次", sub.SubTitle(), add))
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 次", add))
}
func (s *Server) handleSubAction(action string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
back := backURL(r, "/customers/"+itoa(sub.CustomerID))
switch action {
case "pause":
if err := s.db.PauseSub(id, 0); err != nil {
s.redirect(w, r, back, "暂停失败:"+err.Error())
return
}
s.logOp(r, "暂停订阅", "subscription", id, sub.SubTitle()+" 暂停")
s.redirect(w, r, back, "已暂停,剩余时长将冻结")
case "resume":
shift, err := s.db.ResumeSub(id, 0)
if err != nil {
s.redirect(w, r, back, "恢复失败:"+err.Error())
return
}
msg := "已恢复"
if shift > 0 {
msg = fmt.Sprintf("已恢复,结束日顺延 %d 天", shift)
}
s.logOp(r, "恢复订阅", "subscription", id, fmt.Sprintf("%s 恢复,顺延 %d 天", sub.SubTitle(), shift))
s.redirect(w, r, back, msg)
case "close":
if err := s.db.CloseSub(id); err != nil {
s.redirect(w, r, back, "结束失败:"+err.Error())
return
}
s.logOp(r, "结束订阅", "subscription", id, sub.SubTitle()+" 结束")
s.redirect(w, r, back, "订阅已结束")
case "reopen":
if err := s.db.ReopenSub(id); err != nil {
s.redirect(w, r, back, "启用失败:"+err.Error())
return
}
s.logOp(r, "重新启用", "subscription", id, sub.SubTitle()+" 重新启用")
s.redirect(w, r, back, "订阅已重新启用")
default:
http.NotFound(w, r)
}
}
}
func (s *Server) handleCheckinSave(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
back := backURL(r, "/calendar?d="+r.PostFormValue("date"))
date := strings.TrimSpace(r.PostFormValue("date"))
status := strings.TrimSpace(r.PostFormValue("status"))
note := strings.TrimSpace(r.PostFormValue("note"))
u := s.currentUser(r)
var opID int64
if u != nil {
opID = u.ID
}
if err := s.db.SaveCheckin(id, date, status, note, opID); err != nil {
s.redirect(w, r, back, "打卡失败:"+err.Error())
return
}
label := "已完成"
if status == "skipped" {
label = "已跳过"
}
s.logOp(r, "打卡", "subscription", id, fmt.Sprintf("%s %s 打卡:%s", sub.SubTitle(), date, label))
s.redirect(w, r, back, "已记录:"+sub.CustomerTail+" "+sub.CustomerName+" "+date)
}
func (s *Server) handleCheckinDelete(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
back := backURL(r, "/calendar")
subID, err := s.db.DeleteCheckin(id, 0)
if err != nil {
s.redirect(w, r, back, "撤销失败:"+err.Error())
return
}
s.logOp(r, "撤销打卡", "subscription", subID, "撤销打卡记录 #"+itoa(id))
s.redirect(w, r, back, "已撤销打卡")
}
+477
View File
@@ -0,0 +1,477 @@
// Package web 提供 HTTP 路由、会话、模板渲染与页面处理器。
package web
import (
"bytes"
"context"
"crypto/rand"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"daili/internal/domain"
"daili/internal/store"
)
//go:embed templates/_layout.html templates/_screen_layout.html templates/_partials.html templates/pages/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
const (
cookieSession = "daili_sid"
cookieFlash = "daili_flash"
sessionTTL = 7 * 24 * time.Hour
pageSize = 20
)
// Config 是服务配置。
type Config struct {
Addr string
DBPath string
BackupKeep int
BackupDir string
BackupEnabled bool
ScreenPublic bool
ConfigPath string
Version string
}
// Server 是应用服务。
type Server struct {
db *store.DB
cfg Config
tpl *renderer
handler http.Handler
started time.Time
}
type ctxKey int
const userCtxKey ctxKey = 1
// New 构造服务。
func New(db *store.DB, cfg Config) (*Server, error) {
r, err := newRenderer()
if err != nil {
return nil, err
}
s := &Server{db: db, cfg: cfg, tpl: r, started: time.Now()}
s.handler = s.routes()
return s, nil
}
// Handler 返回 HTTP 处理器。
func (s *Server) Handler() http.Handler { return s.handler }
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic(err)
}
mux.Handle("GET /static/", cacheControl(http.StripPrefix("/static/", http.FileServerFS(sub))))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginPost)
mux.HandleFunc("GET /setup", s.handleSetupPage)
mux.HandleFunc("POST /setup", s.handleSetupPost)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/calendar", http.StatusFound)
})
mux.HandleFunc("GET /calendar", s.auth(s.handleCalendar))
mux.HandleFunc("GET /customers", s.auth(s.handleCustomers))
mux.HandleFunc("GET /customers/new", s.auth(s.handleCustomerNewPage))
mux.HandleFunc("POST /customers", s.auth(s.handleCustomerCreate))
mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail))
mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage))
mux.HandleFunc("POST /customers/{id}/edit", s.auth(s.handleCustomerUpdate))
mux.HandleFunc("POST /customers/{id}/subscriptions", s.auth(s.handleSubCreate))
mux.HandleFunc("GET /subscriptions", s.auth(s.handleSubscriptions))
mux.HandleFunc("POST /subscriptions/{id}/extend", s.auth(s.handleSubExtend))
mux.HandleFunc("POST /subscriptions/{id}/pause", s.auth(s.handleSubAction("pause")))
mux.HandleFunc("POST /subscriptions/{id}/resume", s.auth(s.handleSubAction("resume")))
mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close")))
mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen")))
mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave))
mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete))
mux.HandleFunc("GET /packages", s.auth(s.handlePackages))
mux.HandleFunc("GET /packages/new", s.auth(s.handlePackageNewPage))
mux.HandleFunc("POST /packages", s.auth(s.handlePackageCreate))
mux.HandleFunc("GET /packages/{id}/edit", s.auth(s.handlePackageEditPage))
mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate))
mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive))
if s.cfg.ScreenPublic {
// 大屏免登录:仅局域网部署时使用,内容为只读看板
mux.HandleFunc("GET /screen", s.handleScreen)
} else {
mux.HandleFunc("GET /screen", s.auth(s.handleScreen))
}
mux.HandleFunc("GET /logs", s.auth(s.handleLogs))
mux.HandleFunc("GET /settings", s.auth(s.handleSettings))
mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave))
mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange))
mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate))
mux.HandleFunc("POST /settings/users/{id}/toggle", s.auth(s.handleUserToggle))
mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow))
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
return s.noCacheHTML(mux)
}
func cacheControl(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=300")
h.ServeHTTP(w, r)
})
}
func (s *Server) noCacheHTML(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/static/") {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
}
h.ServeHTTP(w, r)
})
}
// ---------- 会话与鉴权 ----------
func randomToken() string {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
return hex.EncodeToString(b)
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User) {
token := randomToken()
if err := s.db.CreateSession(token, u.ID, sessionTTL, clientIP(r)); err != nil {
log.Printf("创建会话失败: %v", err)
}
http.SetCookie(w, &http.Cookie{
Name: cookieSession, Value: token, Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()),
})
}
func (s *Server) userFromRequest(r *http.Request) *store.User {
c, err := r.Cookie(cookieSession)
if err != nil || c.Value == "" {
return nil
}
u, err := s.db.SessionUser(c.Value)
if err != nil {
return nil
}
return u
}
func (s *Server) currentUser(r *http.Request) *store.User {
if u, ok := r.Context().Value(userCtxKey).(*store.User); ok {
return u
}
return nil
}
func sameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return strings.EqualFold(u.Host, r.Host)
}
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && !sameOrigin(r) {
http.Error(w, "跨站请求被拒绝", http.StatusForbidden)
return
}
u := s.userFromRequest(r)
if u == nil {
if r.Method == http.MethodGet {
next := url.QueryEscape(r.URL.RequestURI())
http.Redirect(w, r, "/login?next="+next, http.StatusFound)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), userCtxKey, u)
h(w, r.WithContext(ctx))
}
}
// ---------- 渲染 ----------
type renderer struct {
pages map[string]*template.Template
}
func newRenderer() (*renderer, error) {
funcs := template.FuncMap{
"seq": func(n int) []int {
out := make([]int, 0, n)
for i := 0; i < n; i++ {
out = append(out, i)
}
return out
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"money": func(cents int) string {
return fmt.Sprintf("%.0f", float64(cents)/100)
},
"pct": func(f float64) string { return fmt.Sprintf("%.2f", f) },
"md": func(s string) string { return mdOf(s) },
"dow": dowCN,
}
base, err := template.New("base").Funcs(funcs).ParseFS(templateFS,
"templates/_layout.html", "templates/_partials.html")
if err != nil {
return nil, err
}
screenBase, err := template.New("screen").Funcs(funcs).ParseFS(templateFS,
"templates/_screen_layout.html", "templates/_partials.html")
if err != nil {
return nil, err
}
pages := map[string]*template.Template{}
entries, err := fs.Glob(templateFS, "templates/pages/*.html")
if err != nil {
return nil, err
}
for _, p := range entries {
name := strings.TrimSuffix(strings.TrimPrefix(p, "templates/pages/"), ".html")
var baseSet *template.Template
if name == "screen" {
baseSet = screenBase
} else {
baseSet = base
}
clone, err := baseSet.Clone()
if err != nil {
return nil, err
}
if _, err := clone.ParseFS(templateFS, p); err != nil {
return nil, fmt.Errorf("解析模板 %s 失败: %w", p, err)
}
pages[name] = clone
}
return &renderer{pages: pages}, nil
}
func mdOf(s string) string {
if len(s) >= 10 {
return s[5:10]
}
return s
}
func dowCN(s string) string {
t, err := domain.ParseDate(s)
if err != nil {
return ""
}
return [...]string{"一", "二", "三", "四", "五", "六", "日"}[domain.WeekdayIndex(t)]
}
// page 是所有页面模板的根数据。
type page struct {
User *store.User
Title string
Nav string
Flash string
Error string
Today time.Time
TodayStr string
WarnDays int
Version string
Query string
Next string
DueToday int
Due3 int
Addr string
D any
}
func (s *Server) warnDays() int {
v, err := strconv.Atoi(s.db.Setting("warn_days", "3"))
if err != nil || v <= 0 {
return 3
}
return v
}
func (s *Server) screenRefresh() int {
v, err := strconv.Atoi(s.db.Setting("screen_refresh", "30"))
if err != nil || v < 5 {
return 30
}
return v
}
func (s *Server) newPage(r *http.Request, title, nav string) *page {
u := s.currentUser(r)
today := domain.Today()
p := &page{
User: u,
Title: title,
Nav: nav,
Today: today,
TodayStr: domain.Date(today),
WarnDays: s.warnDays(),
Version: s.cfg.Version,
Query: r.URL.Query().Get("q"),
Next: r.URL.Query().Get("next"),
}
if u != nil {
if due, due3, err := s.db.Badges(today); err == nil {
p.DueToday, p.Due3 = due, due3
}
}
return p
}
func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(cookieFlash)
if err != nil || c.Value == "" {
return ""
}
msg, _ := url.QueryUnescape(c.Value)
http.SetCookie(w, &http.Cookie{Name: cookieFlash, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
return msg
}
func setFlash(w http.ResponseWriter, msg string) {
http.SetCookie(w, &http.Cookie{
Name: cookieFlash, Value: url.QueryEscape(msg), Path: "/",
MaxAge: 30, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) redirect(w http.ResponseWriter, r *http.Request, to, flash string) {
if flash != "" {
setFlash(w, flash)
}
http.Redirect(w, r, to, http.StatusSeeOther)
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, d *page) {
if d.User == nil {
d.User = s.currentUser(r)
}
if d.Flash == "" {
d.Flash = s.popFlash(w, r)
}
if d.Today.IsZero() {
d.Today = domain.Today()
d.TodayStr = domain.Date(d.Today)
}
if d.WarnDays == 0 {
d.WarnDays = s.warnDays()
}
tpl, ok := s.tpl.pages[name]
if !ok {
log.Printf("模板不存在: %s", name)
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
layout := "layout"
if name == "screen" {
layout = "screenlayout"
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, layout, d); err != nil {
log.Printf("渲染 %s 失败: %v", name, err)
http.Error(w, "页面渲染失败: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = buf.WriteTo(w)
}
func (s *Server) fail(w http.ResponseWriter, r *http.Request, name string, msg string, d *page) {
if d == nil {
d = s.newPage(r, "出错了", "")
}
d.Error = msg
w.WriteHeader(http.StatusBadRequest)
s.render(w, r, name, d)
}
// ---------- 参数 ----------
func (s *Server) queryInt(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil {
return def
}
return v
}
func (s *Server) formInt(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue(key)))
if err != nil {
return def
}
return v
}
func pathID(r *http.Request) int64 {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
return 0
}
return id
}
// backURL 取安全的返回地址。
func backURL(r *http.Request, def string) string {
if b := strings.TrimSpace(r.PostFormValue("back")); strings.HasPrefix(b, "/") && !strings.HasPrefix(b, "//") {
return b
}
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil && strings.EqualFold(u.Host, r.Host) {
p := u.RequestURI()
if strings.HasPrefix(p, "/") {
return p
}
}
}
return def
}
+688
View File
@@ -0,0 +1,688 @@
/* ============================================================
代打台 · 管理端(浅色「接单台」)
纸感底色 + 墨色文字 + 松墨绿主色;色签(ticket)是唯一强装饰
============================================================ */
:root {
--paper: #f7f6f2;
--surface: #ffffff;
--ink: #1a1c21;
--ink-2: #43474f;
--muted: #7a7e86;
--line: #e3e0d6;
--line-2: #efede6;
--accent: #2f5d50;
--accent-ink: #22453b;
--accent-soft: #e7efea;
--danger: #b4231f;
--danger-soft: #fbeceb;
--warn: #a85e12;
--warn-soft: #fbf1e3;
--info: #41556b;
--info-soft: #eaeef2;
--radius: 3px;
--mono: "Bahnschrift", "Consolas", "SF Mono", ui-monospace, monospace;
--sans: "Microsoft YaHei UI", "PingFang SC", "Hiragino Sans GB", system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; padding: 0; }
body {
background:
linear-gradient(var(--line-2) 1px, transparent 1px) 0 0 / 100% 32px,
var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 2px; }
h1, h2, h3 { margin: 0; font-weight: 700; letter-spacing: -0.01em; }
h1 { font-size: 22px; }
h2 { font-size: 15px; }
h3 { font-size: 13px; }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
.small { font-size: 12px; }
.muted { color: var(--muted); }
.right { text-align: right; }
/* ---------- 顶栏 ---------- */
.topbar {
position: sticky; top: 0; z-index: 40;
display: flex; align-items: center; gap: 14px;
padding: 0 20px; height: 56px;
background: rgba(247, 246, 242, 0.94);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 8px; color: var(--ink); text-decoration: none; }
.brand:hover { text-decoration: none; }
.brand-mark {
display: grid; place-items: center;
width: 26px; height: 26px;
background: var(--accent); color: #fff;
font-size: 14px; font-weight: 700;
border-radius: 2px;
box-shadow: 2px 2px 0 rgba(47, 93, 80, 0.18);
}
.brand-name { font-weight: 700; letter-spacing: 0.06em; }
.search { position: relative; flex: 1 1 260px; max-width: 380px; }
.search input {
width: 100%; height: 34px;
padding: 0 34px 0 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
font: inherit; color: var(--ink);
}
.search input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.search kbd {
position: absolute; right: 8px; top: 7px;
font-family: var(--mono); font-size: 11px;
color: var(--muted);
border: 1px solid var(--line); border-bottom-width: 2px;
border-radius: 3px; padding: 0 5px; background: var(--paper);
}
.nav { display: flex; gap: 2px; }
.nav a {
padding: 6px 10px; border-radius: var(--radius);
color: var(--ink-2); font-size: 13px;
}
.nav a:hover { background: var(--surface); text-decoration: none; }
.nav a.on { background: var(--accent); color: #fff; }
.nav a.nav-screen { color: var(--accent); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.pill {
display: inline-flex; align-items: center; gap: 5px;
height: 28px; padding: 0 10px;
border: 1px solid var(--line); border-radius: 999px;
background: var(--surface);
font-size: 12px; color: var(--ink-2); white-space: nowrap;
}
.pill b { font-family: var(--mono); font-size: 13px; }
.pill:hover { text-decoration: none; border-color: var(--accent); }
.pill-due b { color: var(--danger); }
.pill-warn b { color: var(--warn); }
.me { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.me a { color: var(--ink-2); }
.link-btn {
border: 0; background: none; padding: 0; cursor: pointer;
color: var(--muted); font: inherit; font-size: 12px;
}
.link-btn:hover { color: var(--danger); }
/* ---------- 提示条 ---------- */
.flash {
position: fixed; z-index: 90;
left: 50%; top: 68px; transform: translateX(-50%);
max-width: min(680px, calc(100vw - 32px));
padding: 9px 16px;
background: var(--accent); color: #fff;
border-radius: var(--radius);
box-shadow: 0 6px 24px rgba(26, 28, 33, 0.16);
font-size: 13px;
animation: flash-in 0.22s ease-out;
}
.flash-error { background: var(--danger); }
.flash.hide { opacity: 0; transition: opacity 0.4s ease; }
@keyframes flash-in { from { opacity: 0; transform: translate(-50%, -6px); } }
/* ---------- 布局 ---------- */
main { padding: 0 20px 40px; }
main.with-top { padding-top: 22px; }
.page { max-width: 1280px; margin: 0 auto; }
.page.narrow { max-width: 860px; }
.page-head {
display: flex; align-items: flex-end; justify-content: space-between;
gap: 16px; flex-wrap: wrap; margin-bottom: 16px;
}
.page-head p { margin: 4px 0 0; font-size: 12px; }
.page-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.foot {
max-width: 1280px; margin: 0 auto; padding: 18px 20px 28px;
font-size: 12px; color: var(--muted);
border-top: 1px solid var(--line);
}
.notice {
margin: 0 0 14px; padding: 10px 14px;
background: var(--warn-soft); border-left: 3px solid var(--warn);
font-size: 13px; border-radius: 0 var(--radius) var(--radius) 0;
}
.notice.note { background: var(--accent-soft); border-left-color: var(--accent); white-space: pre-wrap; }
/* ---------- 按钮与表单 ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
height: 34px; padding: 0 14px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); color: var(--ink);
font: inherit; font-size: 13px; cursor: pointer;
white-space: nowrap; text-decoration: none;
}
.btn:hover { border-color: var(--ink-2); text-decoration: none; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-ink); border-color: var(--accent-ink); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--ink-2); }
.btn-ghost:hover { background: var(--surface); border-color: var(--line); }
.btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
.btn-square { width: 34px; padding: 0; font-size: 18px; line-height: 1; }
.btn-block { width: 100%; }
.btn.disabled { opacity: 0.4; pointer-events: none; }
form { margin: 0; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 14px; }
.field > span { font-size: 12px; color: var(--ink-2); font-weight: 600; }
.field > span i { color: var(--danger); font-style: normal; }
.field input[type="text"], .field input[type="password"], .field input[type="number"],
.field input[type="date"], .field input[type="search"], .field select, .field textarea {
width: 100%; min-height: 34px; padding: 6px 10px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); font: inherit; color: var(--ink);
}
.field textarea { resize: vertical; line-height: 1.6; }
.field input:focus, .field select:focus, .field textarea:focus {
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
.field input[type="color"] { width: 100%; height: 34px; padding: 2px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); cursor: pointer; }
.fieldset { border: 0; padding: 0; }
.radios { display: grid; gap: 8px; }
.radios label {
display: grid; grid-template-columns: 18px 1fr; grid-template-rows: auto auto;
column-gap: 10px; align-items: center;
padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); cursor: pointer;
}
.radios label:hover { border-color: var(--accent); }
.radios input { grid-row: span 2; margin: 0; accent-color: var(--accent); }
.radios b { font-size: 13px; }
.radios small { grid-column: 2; color: var(--muted); font-size: 12px; }
.input-suffix { position: relative; }
.input-suffix input { padding-right: 40px; }
.input-suffix b {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
font-size: 12px; color: var(--muted); font-weight: 400;
}
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 14px; }
.actions { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.actions.wrap { flex-wrap: wrap; }
.inline-form {
display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap;
margin-top: 14px; padding: 12px;
background: var(--surface); border: 1px dashed var(--line); border-radius: var(--radius);
}
.inline-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--ink-2); }
.inline-field input, .inline-field select {
height: 32px; padding: 0 8px; min-width: 130px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); font: inherit; font-size: 13px; color: var(--ink);
}
.inline-form select[name="package_id"] { min-width: 220px; }
.form.card, .section.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
}
.filterbar { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
.filterbar input[type="search"], .filterbar select {
height: 34px; padding: 0 10px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); font: inherit; color: var(--ink);
}
.filterbar input[type="search"] { flex: 1 1 260px; max-width: 420px; }
/* ---------- 认证页 ---------- */
.auth { min-height: 100vh; display: grid; place-items: center; padding: 32px 16px; }
.auth-card {
width: 100%; max-width: 380px;
background: var(--surface); border: 1px solid var(--line);
border-top: 3px solid var(--accent);
padding: 28px;
box-shadow: 0 18px 40px -32px rgba(26, 28, 33, 0.5);
}
.auth-brand { display: flex; align-items: center; gap: 8px; font-weight: 700; letter-spacing: 0.06em; margin-bottom: 18px; }
.auth-card h1 { font-size: 20px; margin-bottom: 4px; }
.auth-card > p { font-size: 12px; margin: 0 0 18px; }
.auth-foot { margin-top: 16px !important; text-align: center; }
.form .btn-block { margin-top: 4px; }
/* ---------- 日历 ---------- */
.cal-page { max-width: 1400px; margin: 0 auto; }
.cal-head {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap; margin-bottom: 14px;
}
.cal-title { display: flex; align-items: center; gap: 10px; }
.cal-title h1 { font-size: 20px; font-family: var(--mono); letter-spacing: 0.02em; }
.cal-stats { display: flex; align-items: center; gap: 16px; font-size: 12px; color: var(--muted); flex-wrap: wrap; }
.cal-stats em {
font-family: var(--mono); font-style: normal; font-size: 16px;
color: var(--ink); margin-right: 4px;
}
.cal-body { display: grid; grid-template-columns: minmax(0, 1fr) 372px; gap: 16px; align-items: start; }
.cal-grid {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.cal-dow {
display: grid; grid-template-columns: repeat(7, 1fr);
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.cal-dow span {
padding: 7px 0; text-align: center;
font-size: 12px; color: var(--muted);
}
.cal-week { display: grid; grid-template-columns: repeat(7, 1fr); }
.cal-cell {
position: relative;
display: flex; flex-direction: column; gap: 3px;
min-height: 112px; padding: 6px 7px 8px;
border-right: 1px solid var(--line-2);
border-bottom: 1px solid var(--line-2);
color: var(--ink); text-decoration: none;
transition: background 0.12s ease;
}
.cal-cell:nth-child(7n) { border-right: 0; }
.cal-cell:hover { background: #f2f7f4; text-decoration: none; }
.cal-cell.out { opacity: 0.4; }
.cal-cell.weekend .dnum { color: var(--muted); }
.cal-cell.sel { box-shadow: inset 0 0 0 2px var(--accent); z-index: 2; }
.cal-cell.today .dnum {
background: var(--accent); color: #fff;
border-radius: 50%; width: 22px; height: 22px;
display: inline-grid; place-items: center;
}
.cal-cell.has-due::after {
content: ""; position: absolute; left: 6px; right: 6px; bottom: 3px;
border-bottom: 1px dashed rgba(180, 35, 31, 0.45);
}
.cal-cell.heat-1 { background: rgba(47, 93, 80, 0.045); }
.cal-cell.heat-2 { background: rgba(47, 93, 80, 0.085); }
.cal-cell.heat-3 { background: rgba(47, 93, 80, 0.13); }
.cal-cell.heat-4 { background: rgba(47, 93, 80, 0.19); }
.cal-cell.heat-0:hover, .cal-cell.heat-1:hover { background: #f2f7f4; }
.cell-top { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.dnum { font-family: var(--mono); font-size: 13px; font-weight: 700; line-height: 1.4; }
.due-badge {
display: inline-flex; align-items: baseline; gap: 2px;
font-size: 10px; padding: 0 5px; line-height: 16px;
background: var(--danger); color: #fff; border-radius: 2px;
white-space: nowrap;
}
.due-badge em { font-style: normal; opacity: 0.85; }
.cell-load { font-family: var(--mono); font-size: 10px; color: var(--accent); }
.cell-tickets { display: flex; flex-direction: column; gap: 2px; margin-top: auto; overflow: hidden; }
.ticket {
display: inline-flex; align-items: baseline; gap: 3px;
max-width: 100%; min-width: 0;
padding: 1px 5px;
font-size: 10.5px; line-height: 16px;
border-left: 3px solid var(--tag, var(--accent));
background: color-mix(in srgb, var(--tag, var(--accent)) 11%, #fff);
border-radius: 1px 2px 2px 1px;
white-space: nowrap;
color: var(--ink-2);
}
.ticket b { font-family: var(--mono); font-size: 11px; color: var(--ink); flex: 0 0 auto; }
.ticket .tk-name { overflow: hidden; text-overflow: ellipsis; }
.ticket i { font-style: normal; font-size: 9.5px; color: var(--muted); flex: 0 0 auto; }
.ticket.done { opacity: 0.55; }
.ticket.done::after { content: "✓"; color: var(--accent); font-size: 10px; }
.ticket.skipped { opacity: 0.4; text-decoration: line-through; }
.ticket.more { border-left-color: var(--line); background: var(--paper); color: var(--muted); }
/* ---------- 当日抽屉 ---------- */
.drawer {
position: sticky; top: 72px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface);
max-height: calc(100vh - 100px);
overflow-y: auto;
}
.drawer-head {
position: sticky; top: 0; z-index: 2;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 12px 14px;
background: var(--paper); border-bottom: 1px solid var(--line);
}
.drawer-head h2 { font-size: 14px; font-family: var(--mono); }
.drawer-tags { display: flex; gap: 4px; }
.drawer-block { padding: 12px 14px; border-bottom: 1px solid var(--line-2); }
.drawer-block:last-child { border-bottom: 0; }
.drawer-block h3 {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--ink-2); margin-bottom: 8px;
text-transform: none; letter-spacing: 0.02em;
}
.drawer-block h3 em {
font-family: var(--mono); font-style: normal; font-size: 11px;
background: var(--paper); border: 1px solid var(--line);
border-radius: 999px; padding: 0 6px; color: var(--muted);
}
.drawer-block p { margin: 0; font-size: 12px; }
.due-list, .task-list, .mini-due, .checkin-list { list-style: none; margin: 0; padding: 0; }
.due-list li {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 6px 0; border-bottom: 1px dashed var(--line-2);
}
.due-list li:last-child { border-bottom: 0; }
.due-main { display: flex; align-items: center; gap: 7px; min-width: 0; color: var(--ink); }
.due-main .pkg, .task-meta .pkg { font-size: 11px; color: var(--muted); }
.due-main .rem { font-family: var(--mono); font-size: 11px; color: var(--danger); }
.task { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; padding: 8px 0; border-bottom: 1px dashed var(--line-2); }
.task:last-child { border-bottom: 0; }
.task-main { min-width: 0; flex: 1 1 auto; }
.task-title { display: flex; align-items: center; gap: 6px; color: var(--ink); }
.task-meta { display: flex; gap: 6px; font-size: 11px; color: var(--muted); margin-top: 2px; }
.task-note { font-size: 11px; color: var(--ink-2); margin-top: 2px; }
.task-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; max-width: 170px; }
.task.done .task-title, .task.skipped .task-title { opacity: 0.5; }
.task.skipped .task-title .ticket { text-decoration: line-through; }
.mini-due li { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; align-items: center; padding: 4px 0; font-size: 12px; }
.sm-date { font-family: var(--mono); font-size: 11px; color: var(--muted); }
/* ---------- 状态标签 ---------- */
.chip {
display: inline-flex; align-items: center;
height: 20px; padding: 0 7px;
font-size: 11px; line-height: 1;
border-radius: 2px; border: 1px solid transparent;
white-space: nowrap;
}
.chip-active { background: var(--accent-soft); color: var(--accent-ink); border-color: rgba(47, 93, 80, 0.2); }
.chip-pending { background: var(--info-soft); color: var(--info); border-color: rgba(65, 85, 107, 0.2); }
.chip-paused { background: var(--info-soft); color: var(--info); border-color: rgba(65, 85, 107, 0.2); }
.chip-expired { background: var(--danger-soft); color: var(--danger); border-color: rgba(180, 35, 31, 0.22); }
.chip-usedup { background: var(--warn-soft); color: var(--warn); border-color: rgba(168, 94, 18, 0.22); }
.chip-closed { background: #f0efeb; color: var(--muted); border-color: var(--line); }
.chip-plain { background: var(--paper); color: var(--ink-2); border-color: var(--line); }
.tagchip {
display: inline-block; margin-right: 4px;
padding: 0 6px; height: 19px; line-height: 19px;
font-size: 11px; color: var(--ink-2);
background: var(--paper); border: 1px solid var(--line); border-radius: 2px;
}
.due { font-size: 11px; }
.due-today { color: var(--danger); font-weight: 700; }
.due-warn { color: var(--warn); }
.due-expired { color: var(--muted); }
.due-ok, .due-none { color: var(--muted); }
.plus { color: var(--accent); font-family: var(--mono); }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; vertical-align: 1px; }
.dot.big { width: 12px; height: 12px; margin: 0; }
/* ---------- 表格 ---------- */
.table-wrap { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th {
text-align: left; padding: 9px 12px;
font-size: 11px; font-weight: 600; color: var(--muted);
letter-spacing: 0.04em;
background: var(--paper);
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.table td { padding: 9px 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr:hover { background: #fbfcfb; }
.table.tight td, .table.tight th { padding: 7px 10px; }
.table .col-dot { width: 28px; }
.table tr.archived { opacity: 0.6; }
.actions-cell { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }
.tail {
display: inline-block; font-family: var(--mono); font-size: 15px; font-weight: 700;
color: var(--accent-ink); letter-spacing: 0.04em;
}
.name { font-weight: 600; color: var(--ink); }
.summary { font-size: 12px; color: var(--ink-2); }
.pager { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 16px; }
.pager-info { font-size: 12px; color: var(--muted); font-family: var(--mono); }
.empty {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 56px 20px; text-align: center;
border: 1px dashed var(--line); border-radius: var(--radius);
background: rgba(255, 255, 255, 0.55);
}
.empty p { margin: 0; color: var(--muted); font-size: 13px; }
.empty-mark {
width: 46px; height: 30px; display: grid; place-items: center;
border: 1px solid var(--line); border-left: 4px solid var(--accent);
background: var(--surface); color: var(--muted); font-size: 12px;
transform: rotate(-4deg);
}
/* ---------- 客户详情 ---------- */
.cust-head { display: grid; grid-template-columns: auto 1fr auto; gap: 20px; align-items: start; margin-bottom: 16px; }
.cust-id { display: flex; align-items: center; gap: 14px; }
.cust-tail {
font-family: var(--mono); font-size: 30px; font-weight: 700; line-height: 1;
padding: 8px 12px;
background: var(--surface); border: 1px solid var(--line);
border-left: 4px solid var(--accent);
letter-spacing: 0.06em;
}
.cust-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
.cust-meta { display: flex; flex-wrap: wrap; gap: 4px 20px; margin: 0; }
.cust-meta dt { font-size: 11px; color: var(--muted); }
.cust-meta dd { margin: 0; font-size: 12px; color: var(--ink-2); }
.section { margin-top: 22px; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
.section-head h2 { font-size: 14px; }
.section-head .muted { font-size: 11px; }
.section.card h2 { margin-bottom: 12px; }
.subgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(268px, 1fr)); gap: 12px; }
.subcard {
background: var(--surface);
border: 1px solid var(--line);
border-left: 4px solid var(--tag, var(--accent));
border-radius: var(--radius);
display: flex; flex-direction: column;
}
.subcard-head { display: flex; align-items: center; gap: 6px; padding: 10px 12px 6px; }
.subcard-head h3 { font-size: 13px; flex: 1 1 auto; }
.subcard-head .unit { font-size: 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 2px; padding: 0 4px; }
.subcard-body { padding: 0 12px 10px; }
.subcard-big { font-family: var(--mono); font-size: 20px; font-weight: 700; letter-spacing: 0.01em; }
.subcard-meta { font-family: var(--mono); font-size: 11px; color: var(--muted); margin-top: 2px; }
.bar { height: 5px; background: var(--line-2); border-radius: 999px; overflow: hidden; margin-top: 8px; }
.bar i { display: block; height: 100%; background: var(--tag, var(--accent)); }
.subcard-foot {
display: flex; gap: 4px; flex-wrap: wrap;
padding: 8px 12px; border-top: 1px dashed var(--line);
background: #fcfcfa;
}
.timeline { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 16px 16px 10px; }
.tl-axis { position: relative; height: 18px; margin-left: 92px; margin-right: 150px; border-bottom: 1px solid var(--line-2); }
.tl-tick { position: absolute; top: 0; font-family: var(--mono); font-size: 10px; color: var(--muted); transform: translateX(-50%); }
.tl-row { display: grid; grid-template-columns: 84px 1fr 142px; gap: 8px; align-items: center; padding: 5px 0; }
.tl-name { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-track { position: relative; height: 20px; background: var(--line-2); border-radius: 2px; }
.tl-bar { position: absolute; top: 4px; height: 12px; background: var(--tag); border-radius: 2px; }
.tl-bar.st-paused { opacity: 0.45; }
.tl-bar.st-closed { background: var(--muted); opacity: 0.6; }
.tl-pause {
position: absolute; top: 4px; height: 12px;
background: repeating-linear-gradient(135deg, #c9c7c0 0 4px, #e6e4dd 4px 8px);
border-radius: 1px;
}
.tl-due { position: absolute; top: 1px; bottom: 1px; width: 2px; background: var(--danger); }
.tl-legend { font-family: var(--mono); font-size: 10px; color: var(--muted); text-align: right; }
.checkin-list li { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px dashed var(--line-2); font-size: 12px; }
.checkin-list li:last-child { border-bottom: 0; }
/* ---------- 追加面板 ---------- */
.extend {
margin-top: 18px;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 12px 32px -26px rgba(26, 28, 33, 0.8);
scroll-margin-top: 80px;
}
.drawer .extend { margin: 12px 14px; }
.extend-head {
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
padding: 12px 14px;
background: var(--accent-soft);
border-bottom: 1px solid rgba(47, 93, 80, 0.2);
}
.extend-head h3 { font-size: 14px; }
.extend-head p { margin: 2px 0 0; font-size: 11px; }
.extend-close { font-size: 20px; line-height: 1; color: var(--ink-2); padding: 0 4px; }
.extend-form { padding: 14px; }
.extend-preview {
display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px;
padding: 10px; margin-bottom: 10px;
background: var(--paper); border: 1px dashed var(--line); border-radius: var(--radius);
}
.extend-preview div { display: flex; flex-direction: column; gap: 1px; }
.extend-preview span { font-size: 10px; color: var(--muted); }
.extend-preview b { font-family: var(--mono); font-size: 13px; font-weight: 600; }
.extend-preview b i { font-style: normal; font-size: 18px; color: var(--accent); }
.extend-preview .add b { color: var(--accent); }
.extend-hint { margin: 0 0 10px; font-size: 11px; color: var(--muted); }
.minis { display: grid; grid-template-columns: repeat(auto-fit, minmax(196px, 1fr)); gap: 12px; margin-bottom: 12px; }
.minimonth { border: 1px solid var(--line); border-radius: var(--radius); padding: 8px; }
.minimonth-title { font-size: 11px; color: var(--ink-2); font-weight: 600; margin-bottom: 6px; text-align: center; }
.minimonth-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.minimonth-grid .dow { text-align: center; font-size: 10px; color: var(--muted); }
.miniday {
height: 28px; border: 1px solid transparent; border-radius: 2px;
background: transparent; font: inherit; font-size: 11px; font-family: var(--mono);
color: var(--ink-2); cursor: pointer; padding: 0;
}
.miniday:hover:not(:disabled) { background: var(--accent-soft); border-color: rgba(47, 93, 80, 0.3); }
.miniday.out { color: #c3c1ba; }
.miniday.old { background: var(--warn-soft); border-color: rgba(168, 94, 18, 0.35); font-weight: 700; }
.miniday.today { outline: 1px solid var(--accent); }
.miniday.picked { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 700; }
.miniday:disabled { color: #d6d4cc; cursor: not-allowed; }
/* ---------- 设置页杂项 ---------- */
.kv { display: grid; gap: 6px; margin: 0 0 14px; }
.kv > div { display: grid; grid-template-columns: 88px 1fr; gap: 10px; align-items: baseline; }
.kv dt { font-size: 11px; color: var(--muted); }
.kv dd { margin: 0; font-size: 12px; word-break: break-all; }
.backups { margin-top: 12px; font-size: 12px; }
.backups summary { cursor: pointer; color: var(--ink-2); }
.backups ul { margin: 8px 0 0; padding-left: 18px; color: var(--muted); }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 14px; flex-wrap: wrap; }
.tabs a {
padding: 7px 12px; font-size: 13px; color: var(--ink-2);
border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.tabs a:hover { text-decoration: none; color: var(--ink); }
.tabs a.on { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
/* ---------- 响应式 ---------- */
@media (max-width: 1180px) {
.cal-body { grid-template-columns: 1fr; }
.drawer { position: static; max-height: none; }
.cust-head { grid-template-columns: 1fr; }
}
@media (max-width: 860px) {
.topbar { height: auto; flex-wrap: wrap; padding: 8px 12px; gap: 8px; }
.search { order: 3; flex-basis: 100%; max-width: none; }
.nav { order: 4; flex-basis: 100%; overflow-x: auto; }
.topbar-right { order: 2; margin-left: auto; }
main { padding: 0 12px 32px; }
.cal-grid { overflow-x: auto; }
.cal-week, .cal-dow { min-width: 680px; }
.cal-cell { min-height: 96px; }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.cust-tail { font-size: 24px; }
.tl-row { grid-template-columns: 70px 1fr; }
.tl-legend { display: none; }
.tl-axis { margin-right: 0; }
.extend-preview { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 560px) {
.pill { display: none; }
.cal-week, .cal-dow { min-width: 0; }
.cal-cell { min-height: 64px; padding: 3px; }
.dnum { font-size: 12px; }
.due-badge { font-size: 9px; padding: 0 4px; line-height: 14px; }
.due-badge em { display: none; }
.cell-load { display: none; }
.cell-tickets { display: none; }
.task { flex-direction: column; }
.task-actions { max-width: none; justify-content: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
+108
View File
@@ -0,0 +1,108 @@
/* 代打台 · 交互增强(无框架,全部渐进增强) */
(function () {
'use strict';
/* 提示条自动淡出 */
document.querySelectorAll('[data-flash]').forEach(function (el) {
setTimeout(function () {
el.classList.add('hide');
setTimeout(function () { el.remove(); }, 450);
}, 4200);
});
/* 按 / 聚焦搜索框 */
document.addEventListener('keydown', function (e) {
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
var tag = (document.activeElement && document.activeElement.tagName) || '';
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag)) return;
var s = document.querySelector('[data-search]');
if (s) { e.preventDefault(); s.focus(); s.select(); }
});
/* 危险操作确认 */
document.querySelectorAll('form[data-confirm]').forEach(function (f) {
f.addEventListener('submit', function (e) {
if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault();
});
});
/* 追加时长面板:点日历挑终止日 + 实时反算 */
function parseDay(s) { return new Date(s + 'T00:00:00'); }
function diffDays(a, b) { return Math.round((b.getTime() - a.getTime()) / 86400000); }
document.querySelectorAll('form[data-extend]').forEach(function (form) {
var base = parseDay(form.getAttribute('data-base'));
var today = parseDay(form.getAttribute('data-today'));
var input = form.querySelector('[data-end-input]');
var endLabel = form.querySelector('[data-end-label]');
var daysEl = form.querySelector('[data-days]');
var remainEl = form.querySelector('[data-remain]');
var buttons = form.querySelectorAll('.miniday');
if (!input) return;
function paint(date) {
if (!date) return;
var d = parseDay(date);
input.value = date;
if (endLabel) endLabel.textContent = date;
var days = diffDays(base, d) + 1;
if (daysEl) daysEl.textContent = days > 0 ? days : 0;
var remain = diffDays(today, d) + 1;
if (remainEl) remainEl.textContent = remain > 0 ? remain : 0;
buttons.forEach(function (b) {
b.classList.toggle('picked', b.getAttribute('data-date') === date);
});
}
buttons.forEach(function (b) {
b.addEventListener('click', function () {
if (!b.disabled) paint(b.getAttribute('data-date'));
});
});
input.addEventListener('change', function () { paint(input.value); });
if (input.value) paint(input.value);
});
/* 新订阅表单:按套餐类型切换「天数 / 次数 / 终止日」 */
document.querySelectorAll('form[data-sub-form]').forEach(function (form) {
var sel = form.querySelector('[data-pkg-select]');
var daysWrap = form.querySelector('[data-days-wrap]');
var countWrap = form.querySelector('[data-count-wrap]');
var endWrap = form.querySelector('[data-end-wrap]');
if (!sel) return;
function sync() {
var opt = sel.options[sel.selectedIndex];
var unit = opt && opt.getAttribute('data-unit');
var amount = opt && opt.getAttribute('data-amount');
if (unit === 'count') {
if (daysWrap) daysWrap.hidden = true;
if (countWrap) { countWrap.hidden = false; if (amount) countWrap.querySelector('input').value = amount; }
if (endWrap) endWrap.hidden = true;
} else if (unit === 'day') {
if (daysWrap) { daysWrap.hidden = false; if (amount) daysWrap.querySelector('input').value = amount; }
if (countWrap) countWrap.hidden = true;
if (endWrap) endWrap.hidden = false;
} else {
if (daysWrap) daysWrap.hidden = false;
if (countWrap) countWrap.hidden = true;
if (endWrap) endWrap.hidden = true;
}
}
sel.addEventListener('change', sync);
sync();
});
/* 套餐表单:切换默认量单位文案 */
document.querySelectorAll('form[data-package-form]').forEach(function (form) {
var label = form.querySelector('[data-unit-label]');
var radios = form.querySelectorAll('input[name="unit"]');
function sync() {
var v = 'day';
radios.forEach(function (r) { if (r.checked) v = r.value; });
if (label) label.textContent = v === 'count' ? '次' : '天';
}
radios.forEach(function (r) { r.addEventListener('change', sync); });
sync();
});
})();
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="6" fill="#2F5D50"/>
<rect x="6" y="14" width="52" height="36" rx="3" fill="none" stroke="#F7F6F2" stroke-width="3"/>
<path d="M10 34h44" stroke="#F7F6F2" stroke-width="2" stroke-dasharray="4 4" opacity="0.55"/>
<rect x="12" y="20" width="14" height="7" fill="#F7F6F2"/>
<rect x="32" y="20" width="20" height="3" fill="#F7F6F2" opacity="0.8"/>
<rect x="32" y="26" width="12" height="3" fill="#F7F6F2" opacity="0.5"/>
<rect x="12" y="42" width="24" height="3" fill="#F7F6F2" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 627 B

+212
View File
@@ -0,0 +1,212 @@
/* ============================================================
代打台 · 可视化大屏(深色「记分板」)
============================================================ */
:root {
--bg: #070a0f;
--panel: #0e141c;
--panel-2: #121a24;
--line: #1b2530;
--ink: #e8eef2;
--dim: #7d8b99;
--mint: #35e0c6;
--mint-dim: rgba(53, 224, 198, 0.14);
--amber: #f5a524;
--red: #f2555a;
--mono: "Bahnschrift", "Consolas", "SF Mono", ui-monospace, monospace;
--sans: "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body.screen-body {
background:
radial-gradient(1200px 600px at 15% -10%, rgba(53, 224, 198, 0.07), transparent 60%),
radial-gradient(900px 500px at 100% 0%, rgba(245, 165, 36, 0.05), transparent 55%),
var(--bg);
color: var(--ink);
font-family: var(--sans);
font-size: 14px;
min-height: 100vh;
overflow-x: hidden;
}
a { color: var(--mint); text-decoration: none; }
a:hover { opacity: 0.8; }
.scr {
display: flex; flex-direction: column;
min-height: 100vh;
padding: clamp(12px, 1.4vw, 22px) clamp(14px, 1.8vw, 28px);
gap: clamp(10px, 1.1vw, 16px);
}
.scr-head { display: flex; align-items: center; gap: 16px; }
.scr-brand {
display: flex; align-items: center; gap: 10px;
font-size: clamp(15px, 1.2vw, 19px); font-weight: 700; letter-spacing: 0.1em;
}
.scr-brand .mark {
display: grid; place-items: center;
width: 30px; height: 30px; border-radius: 3px;
background: var(--mint); color: #04211d; font-size: 15px;
box-shadow: 0 0 22px rgba(53, 224, 198, 0.45);
}
.scr-brand em { font-style: normal; color: var(--dim); font-weight: 400; letter-spacing: 0.24em; font-size: 0.7em; }
.scr-clock { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; }
.scr-clock b {
font-family: var(--mono); font-size: clamp(24px, 2.4vw, 36px);
font-weight: 600; line-height: 1;
color: var(--ink); letter-spacing: 0.04em;
text-shadow: 0 0 22px rgba(53, 224, 198, 0.25);
}
.scr-clock span { font-family: var(--mono); font-size: 11px; color: var(--dim); letter-spacing: 0.1em; }
.scr-links { font-size: 11px; color: var(--dim); }
/* KPI */
.scr-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(8px, 0.9vw, 14px); }
.kpi {
position: relative; overflow: hidden;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line);
border-radius: 4px;
padding: clamp(10px, 1vw, 16px) clamp(12px, 1.2vw, 20px);
}
.kpi::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--mint); opacity: 0.75;
}
.kpi-warm::before { background: var(--amber); }
.kpi-hot::before { background: var(--red); }
.kpi span { display: block; font-size: 11px; color: var(--dim); letter-spacing: 0.16em; }
.kpi b {
font-family: var(--mono); font-size: clamp(28px, 3.4vw, 52px); font-weight: 700;
line-height: 1.15; letter-spacing: -0.01em;
}
.kpi i { font-style: normal; font-size: 11px; color: var(--dim); margin-left: 4px; }
.kpi-hot b { color: var(--red); }
.kpi-warm b { color: var(--amber); }
/* 主区 */
.scr-main { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); flex: 1 1 auto; }
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 4px;
padding: clamp(10px, 1vw, 16px);
display: flex; flex-direction: column; min-height: 0;
}
.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.panel-head h2 {
margin: 0; font-size: clamp(12px, 0.95vw, 14px); font-weight: 600; letter-spacing: 0.08em;
color: var(--ink);
}
.panel-head h2::before {
content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 7px;
background: var(--mint); vertical-align: 2px;
}
.panel-head span { font-size: 11px; color: var(--dim); font-family: var(--mono); }
/* 热力月历 */
.heat-grid { display: flex; flex-direction: column; gap: 4px; flex: 1 1 auto; }
.heat-dow, .heat-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.heat-row { flex: 1 1 0; min-height: 0; }
.heat-dow { flex: 0 0 auto; }
.heat-dow span { text-align: center; font-size: 10px; color: var(--dim); }
.heat-cell {
position: relative;
display: flex; flex-direction: column; justify-content: space-between;
min-height: 34px;
padding: 5px 6px;
border: 1px solid var(--line);
border-radius: 3px;
background: #0a1017;
}
.heat-cell span { font-family: var(--mono); font-size: 10px; color: var(--dim); }
.heat-cell b {
align-self: flex-end; font-family: var(--mono); font-size: clamp(12px, 1.1vw, 16px);
color: var(--mint); font-weight: 700;
}
.heat-cell.out { opacity: 0.28; }
.heat-cell.today { border-color: var(--mint); box-shadow: 0 0 0 1px rgba(53, 224, 198, 0.35), 0 0 18px rgba(53, 224, 198, 0.16); }
.heat-cell.h1 { background: rgba(53, 224, 198, 0.08); border-color: rgba(53, 224, 198, 0.22); }
.heat-cell.h2 { background: rgba(53, 224, 198, 0.15); border-color: rgba(53, 224, 198, 0.3); }
.heat-cell.h3 { background: rgba(53, 224, 198, 0.24); border-color: rgba(53, 224, 198, 0.42); }
.heat-cell.h4 { background: rgba(53, 224, 198, 0.36); border-color: rgba(53, 224, 198, 0.6); }
.heat-cell.h3 b, .heat-cell.h4 b { color: #eafffb; }
.heat-legend { display: flex; align-items: center; gap: 5px; margin-top: 10px; font-size: 10px; color: var(--dim); flex: 0 0 auto; }
.heat-legend i { width: 18px; height: 9px; border-radius: 2px; border: 1px solid #26313d; }
.heat-legend .h0 { background: #0a1017; }
.heat-legend .h1 { background: rgba(53, 224, 198, 0.16); }
.heat-legend .h2 { background: rgba(53, 224, 198, 0.28); }
.heat-legend .h3 { background: rgba(53, 224, 198, 0.42); }
.heat-legend .h4 { background: rgba(53, 224, 198, 0.6); }
/* TOP10 */
.top-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; overflow: hidden; }
.top-item {
display: grid; grid-template-columns: 22px auto 1fr auto auto;
grid-template-areas: "rank tail name pkg remain" "rank bar bar bar bar";
column-gap: 8px; row-gap: 3px;
align-items: center;
padding: 5px 8px;
border-radius: 3px;
background: #0a1017;
border-left: 2px solid transparent;
}
.top-item .rank { grid-area: rank; font-family: var(--mono); font-size: 11px; color: var(--dim); }
.top-tail { grid-area: tail; font-family: var(--mono); font-size: clamp(13px, 1.15vw, 17px); font-weight: 700; letter-spacing: 0.03em; }
.top-name { grid-area: name; font-size: 12px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-pkg {
grid-area: pkg; font-size: 10px; color: var(--dim);
border-left: 2px solid var(--tag); padding-left: 5px;
}
.top-remain { grid-area: remain; font-family: var(--mono); font-size: 12px; color: var(--mint); }
.top-bar { grid-area: bar; height: 3px; background: #16202b; border-radius: 999px; overflow: hidden; }
.top-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--mint), rgba(53, 224, 198, 0.35)); }
.top-item.lv-warn { border-left-color: var(--amber); }
.top-item.lv-warn .top-remain { color: var(--amber); }
.top-item.lv-warn .top-bar i { background: linear-gradient(90deg, var(--amber), rgba(245, 165, 36, 0.3)); }
.top-item.lv-today, .top-item.lv-expired { border-left-color: var(--red); }
.top-item.lv-today .top-remain, .top-item.lv-expired .top-remain { color: var(--red); }
.top-item.lv-today .top-bar i, .top-item.lv-expired .top-bar i { background: linear-gradient(90deg, var(--red), rgba(242, 85, 90, 0.3)); }
/* 底部 */
.scr-bottom { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); }
.today-list, .recent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; max-height: 22vh; overflow: hidden; }
.today-list li {
display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center;
padding: 4px 6px; border-bottom: 1px solid rgba(27, 37, 48, 0.6);
}
.tchip {
display: inline-flex; align-items: baseline; gap: 3px;
padding: 1px 6px; font-size: 11px;
border-left: 3px solid var(--tag);
background: rgba(255, 255, 255, 0.04);
border-radius: 1px 2px 2px 1px;
}
.tchip b { font-family: var(--mono); font-size: 12px; color: var(--ink); }
.today-list .pkg, .recent-list .pkg { font-size: 11px; color: var(--dim); }
.today-list .rem { font-family: var(--mono); font-size: 11px; color: var(--mint); }
.recent-list li { display: flex; align-items: center; gap: 8px; padding: 3px 4px; font-size: 11px; border-bottom: 1px solid rgba(27, 37, 48, 0.6); }
.recent-list .time { color: var(--dim); font-size: 10px; }
.recent-list .who b { font-family: var(--mono); font-size: 12px; margin-right: 4px; }
.recent-list .plus { margin-left: auto; color: var(--mint); font-family: var(--mono); font-style: normal; }
.recent-list .op { color: var(--dim); }
.scr-foot { font-size: 10px; color: #47535f; text-align: right; letter-spacing: 0.12em; }
.scr-empty { margin: auto; color: var(--dim); font-size: 12px; text-align: center; }
@media (max-width: 1100px) {
.scr-kpis { grid-template-columns: repeat(2, 1fr); }
.scr-main, .scr-bottom { grid-template-columns: 1fr; }
.heat-cell { min-height: 34px; }
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
+29
View File
@@ -0,0 +1,29 @@
/* 大屏:时钟 + 全屏快捷键 */
(function () {
'use strict';
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function tick() {
var d = new Date();
var clock = document.querySelector('[data-clock]');
if (clock) clock.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
var dateEl = document.querySelector('[data-date]');
if (dateEl) {
dateEl.textContent = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
' 周' + '日一二三四五六'.charAt(d.getDay());
}
}
tick();
setInterval(tick, 1000);
document.addEventListener('keydown', function (e) {
if (e.key === 'f' || e.key === 'F') {
if (document.fullscreenElement) {
document.exitFullscreen();
} else if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen();
}
}
});
})();
+41
View File
@@ -0,0 +1,41 @@
{{define "layout"}}<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · 代打台</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
{{if .User}}
<header class="topbar">
<a class="brand" href="/calendar"><span class="brand-mark">代</span><span class="brand-name">代打台</span></a>
<form class="search" action="/customers" method="get" role="search">
<input type="search" name="q" value="{{.Query}}" placeholder="手机尾号 / 姓名 / 游戏名" aria-label="搜索客户" autocomplete="off" data-search>
<kbd>/</kbd>
</form>
<nav class="nav">
<a href="/calendar" class="{{if eq .Nav "calendar"}}on{{end}}">日历</a>
<a href="/customers" class="{{if eq .Nav "customers"}}on{{end}}">客户</a>
<a href="/subscriptions" class="{{if eq .Nav "subscriptions"}}on{{end}}">订阅</a>
<a href="/packages" class="{{if eq .Nav "packages"}}on{{end}}">套餐</a>
<a href="/screen" target="_blank" rel="noopener" class="nav-screen">大屏 ↗</a>
</nav>
<div class="topbar-right">
<a class="pill pill-due" href="/subscriptions?state=due_today" title="今天到期的订阅">今日到期 <b>{{.DueToday}}</b></a>
<a class="pill pill-warn" href="/subscriptions?state=due3" title="3 天内到期">3 天内 <b>{{.Due3}}</b></a>
<div class="me">
<a href="/settings">{{if .User.DisplayName}}{{.User.DisplayName}}{{else}}{{.User.Username}}{{end}}</a>
<form method="post" action="/logout"><button type="submit" class="link-btn">退出</button></form>
</div>
</div>
</header>
{{end}}
{{if .Flash}}<div class="flash" data-flash>{{.Flash}}</div>{{end}}
{{if .Error}}<div class="flash flash-error">{{.Error}}</div>{{end}}
<main{{if .User}} class="with-top"{{end}}>{{template "content" .}}</main>
{{if .User}}<footer class="foot">代打台 v{{.Version}} · <a href="/logs">操作日志</a> · <a href="/settings">设置</a> · <a href="/screen" target="_blank" rel="noopener">大屏</a></footer>{{end}}
<script src="/static/app.js" defer></script>
</body>
</html>{{end}}
+135
View File
@@ -0,0 +1,135 @@
{{define "state-chip"}}<span class="chip chip-{{.StateClass}}">{{.StateLabel}}</span>{{end}}
{{define "pager"}}
{{if gt .Pages 1}}
<nav class="pager">
{{if .Prev}}<a class="btn btn-sm" href="{{.Prev}}">← 上一页</a>{{else}}<span class="btn btn-sm disabled">← 上一页</span>{{end}}
<span class="pager-info">{{.Page}} / {{.Pages}} 页 · 共 {{.Total}} 条</span>
{{if .Next}}<a class="btn btn-sm" href="{{.Next}}">下一页 →</a>{{else}}<span class="btn btn-sm disabled">下一页 →</span>{{end}}
</nav>
{{end}}
{{end}}
{{define "empty"}}
<div class="empty">
<div class="empty-mark">票</div>
<p>{{.Text}}</p>
{{if .Action}}<a class="btn btn-primary" href="{{.Action}}">{{.ActionText}}</a>{{end}}
</div>
{{end}}
{{define "sub-card"}}
<article class="subcard" id="sub-{{.ID}}" style="--tag:{{.PackageColor}}">
<div class="subcard-head">
<span class="tag"></span>
<h3>{{.PackageName}}</h3>
{{template "state-chip" .}}
<span class="unit">{{.UnitLabel}}</span>
</div>
<div class="subcard-body">
<div class="subcard-big">{{.RemainText}}</div>
<div class="subcard-meta">{{.PeriodText}}</div>
{{if .DueText}}<div class="due due-{{.Eval.Level}}">{{.DueText}}</div>{{end}}
<div class="bar"><i style="width:{{.Progress}}%"></i></div>
</div>
<div class="subcard-foot">
{{if ne .Status "closed"}}
{{if .CanCheckinToday}}
<form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{.Today}}">
<input type="hidden" name="status" value="done">
<button class="btn btn-sm">今日打卡</button>
</form>
{{end}}
<a class="btn btn-primary btn-sm" href="?extend={{.ID}}#extend">{{if eq .Unit "day"}}追加时长{{else}}追加次数{{end}}</a>
{{if eq .Status "active"}}
<form method="post" action="/subscriptions/{{.ID}}/pause" data-confirm="暂停后剩余时长冻结,确认暂停?">
<button class="btn btn-sm">暂停</button>
</form>
{{else if eq .Status "paused"}}
<form method="post" action="/subscriptions/{{.ID}}/resume">
<button class="btn btn-sm">恢复</button>
</form>
{{end}}
<form method="post" action="/subscriptions/{{.ID}}/close" data-confirm="结束后该订阅不再出现在待打清单,确认结束?">
<button class="btn btn-sm btn-ghost">结束</button>
</form>
{{else}}
<form method="post" action="/subscriptions/{{.ID}}/reopen">
<button class="btn btn-sm">重新启用</button>
</form>
{{end}}
</div>
</article>
{{end}}
{{define "extend-panel"}}
<section class="extend" id="extend" style="--tag:{{.Sub.PackageColor}}">
<header class="extend-head">
<div>
<h3>给 {{.Sub.CustomerTail}} {{.Sub.CustomerName}} 追加{{if .IsDay}}时长{{else}}次数{{end}}</h3>
<p class="muted">{{.Sub.PackageName}} · {{.View.RemainText}} · {{.View.PeriodText}}</p>
</div>
<a class="extend-close" href="{{.Back}}" aria-label="关闭">×</a>
</header>
{{if .IsDay}}
<form method="post" action="/subscriptions/{{.Sub.ID}}/extend" class="extend-form"
data-extend data-base="{{.Base}}" data-old-end="{{.OldEnd}}" data-today="{{.Today}}">
<input type="hidden" name="back" value="{{.Back}}">
<input type="hidden" name="new_end_date" value="{{.Suggested}}" data-end-input>
<div class="extend-preview">
<div><span>计算起点</span><b data-base-label>{{.Base}}</b></div>
<div><span>原结束日</span><b>{{.OldEnd}}</b></div>
<div><span>新结束日</span><b data-end-label>{{.Suggested}}</b></div>
<div class="add"><span>本次追加</span><b><i data-days>{{.SuggestedDays}}</i> 天</b></div>
<div><span>追加后剩余</span><b><i data-remain>—</i> 天</b></div>
</div>
<p class="extend-hint">在下面的日历上点选新的终止日期;未到期的订阅从原结束日次日起算,已到期的从今天起算。</p>
<div class="minis">
{{range .Months}}
<div class="minimonth">
<div class="minimonth-title">{{.Title}}</div>
<div class="minimonth-grid">
<span class="dow">一</span><span class="dow">二</span><span class="dow">三</span><span class="dow">四</span><span class="dow">五</span><span class="dow">六</span><span class="dow">日</span>
{{range .Weeks}}{{range .}}
<button type="button" class="miniday{{if not .InMonth}} out{{end}}{{if .IsOldEnd}} old{{end}}{{if .IsToday}} today{{end}}" data-date="{{.Date}}"{{if .Disabled}} disabled{{end}}>{{.Day}}</button>
{{end}}{{end}}
</div>
</div>
{{end}}
</div>
<label class="field"><span>备注</span>
<input type="text" name="note" maxlength="120" placeholder="例如:微信转账 30 天 / 补送 3 天">
</label>
<div class="actions">
<button type="submit" class="btn btn-primary">确认追加</button>
<a class="btn" href="{{.Back}}">取消</a>
</div>
</form>
{{else}}
<form method="post" action="/subscriptions/{{.Sub.ID}}/extend" class="extend-form">
<input type="hidden" name="back" value="{{.Back}}">
<div class="extend-preview">
<div><span>当前剩余</span><b>{{.View.RemainText}}</b></div>
<div><span>已用 / 总数</span><b>{{.Sub.UsedCount}} / {{.Sub.TotalCount}}</b></div>
{{if .Sub.EndDate}}<div><span>有效期至</span><b>{{.Sub.EndDate}}</b></div>{{end}}
</div>
<div class="grid-2">
<label class="field"><span>追加次数</span>
<input type="number" name="add_count" min="1" max="10000" value="{{.DefaultAmount}}" required>
</label>
<label class="field"><span>新的有效期(留空不改)</span>
<input type="date" name="new_end_opt">
</label>
</div>
<label class="field"><span>备注</span>
<input type="text" name="note" maxlength="120" placeholder="例如:加购 10 次副本">
</label>
<div class="actions">
<button type="submit" class="btn btn-primary">确认追加</button>
<a class="btn" href="{{.Back}}">取消</a>
</div>
</form>
{{end}}
</section>
{{end}}
@@ -0,0 +1,15 @@
{{define "screenlayout"}}<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="{{.D.Refresh}}">
<title>{{.Title}} · 代打台</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/screen.css">
</head>
<body class="screen-body">
{{template "content" .}}
<script src="/static/screen.js" defer></script>
</body>
</html>{{end}}
+146
View File
@@ -0,0 +1,146 @@
{{define "content"}}
<div class="cal-page">
<header class="cal-head">
<div class="cal-title">
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.PrevY}}&m={{.D.PrevM}}" aria-label="上个月">‹</a>
<h1>{{.D.Title}}</h1>
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.NextY}}&m={{.D.NextM}}" aria-label="下个月">›</a>
<a class="btn btn-sm" href="/calendar">回到今天</a>
</div>
<div class="cal-stats">
<span><em>{{.D.MonthDue}}</em>本月到期</span>
<span><em>{{.D.MonthTasks}}</em>本月待打</span>
<span><em>{{.D.MonthDone}}</em>已完成</span>
<a class="btn btn-primary btn-sm" href="/customers/new">+ 新建客户</a>
</div>
</header>
{{if .D.NoPackages}}
<div class="notice">还没有服务套餐,先去 <a href="/packages/new">新建套餐</a>,再给客户开通。</div>
{{else if .D.NoCustomers}}
<div class="notice">还没有客户,<a href="/customers/new">新建第一位客户</a>。</div>
{{end}}
<div class="cal-body">
<div class="cal-grid" role="grid">
<div class="cal-dow" role="row">
{{range .D.Weekdays}}<span>{{.}}</span>{{end}}
</div>
{{range .D.Weeks}}
<div class="cal-week" role="row">
{{range .}}
<a class="cal-cell heat-{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsSelected}} sel{{end}}{{if .IsWeekend}} weekend{{end}}{{if .DueCount}} has-due{{end}}"
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}" role="gridcell">
<div class="cell-top">
<span class="dnum">{{.Day}}</span>
{{if .DueCount}}<span class="due-badge"><em>到期</em>{{.DueCount}}</span>{{end}}
</div>
{{if .TaskCount}}
<div class="cell-load">{{.DoneCount}}/{{.TaskCount}} 单</div>
{{end}}
<div class="cell-tickets">
{{range .Tickets}}
<span class="ticket{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}" style="--tag:{{.Color}}"><b>{{.Tail}}</b><span class="tk-name">{{.Name}}</span><i>{{.Pkg}}</i></span>
{{end}}
{{if gt .More 0}}<span class="ticket more">+{{.More}}</span>{{end}}
</div>
</a>
{{end}}
</div>
{{end}}
</div>
<aside class="drawer">
<header class="drawer-head">
<h2>{{.D.SelectedLabel}}</h2>
<div class="drawer-tags">
{{if .D.SelectedIsToday}}<span class="chip chip-active">今天</span>{{end}}
{{if .D.SelectedIsPast}}<span class="chip chip-closed">已过去</span>{{end}}
<span class="chip chip-plain">{{len .D.Tasks}} 待打</span>
</div>
</header>
<section class="drawer-block">
<h3>这天到期 <em>{{len .D.Due}}</em></h3>
{{if .D.Due}}
<ul class="due-list">
{{range .D.Due}}
<li>
<a class="due-main" href="/customers/{{.CustomerID}}">
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
<span class="rem">{{.RemainShort}}</span>
</a>
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&extend={{.ID}}#extend">追加</a>
</li>
{{end}}
</ul>
{{else}}<p class="muted">没有到期订阅。</p>{{end}}
</section>
<section class="drawer-block">
<h3>当日待打 <em>{{len .D.Tasks}}</em></h3>
{{if .D.Tasks}}
<ul class="task-list">
{{range .D.Tasks}}
<li class="task{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}">
<div class="task-main">
<a class="task-title" href="/customers/{{.CustomerID}}">
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
</a>
<div class="task-meta">
<span>{{.RemainText}}</span>
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
</div>
{{if .Note}}<div class="task-note">{{.Note}}{{if .Operator}} · {{.Operator}}{{end}}</div>{{end}}
</div>
<div class="task-actions">
{{if or .Done .Skipped}}
<span class="chip {{if .Done}}chip-active{{else}}chip-paused{{end}}">{{if .Done}}已完成{{else}}已跳过{{end}}</span>
<form method="post" action="/checkins/{{.CheckinID}}/delete">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-ghost">撤销</button>
</form>
{{else}}
<form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="status" value="done">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-primary">完成</button>
</form>
<form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="status" value="skipped">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-ghost">跳过</button>
</form>
{{end}}
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&extend={{.ID}}#extend">追加</a>
</div>
</li>
{{end}}
</ul>
{{else}}<p class="muted">这天没有待打任务。</p>{{end}}
</section>
{{if .D.Extend}}{{template "extend-panel" .D.Extend}}{{end}}
<section class="drawer-block">
<h3>近 7 天到期</h3>
{{if .D.OtherDue}}
<ul class="mini-due">
{{range .D.OtherDue}}
<li>
<a href="/customers/{{.CustomerID}}"><span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span></a>
<span class="muted">{{.PackageName}}</span>
<span class="sm-date">{{.EndDate}}</span>
</li>
{{end}}
</ul>
{{else}}<p class="muted">7 天内没有其它到期。</p>{{end}}
</section>
</aside>
</div>
</div>
{{end}}
@@ -0,0 +1,152 @@
{{define "content"}}
<div class="page">
<header class="cust-head">
<div class="cust-id">
<span class="cust-tail">{{.D.Customer.Tail}}</span>
<div>
<h1>{{.D.Customer.Name}}</h1>
<div class="cust-tags">
{{range .D.Customer.TagList}}<span class="tagchip">{{.}}</span>{{end}}
</div>
</div>
</div>
<dl class="cust-meta">
{{if .D.Customer.GameName}}<div><dt>游戏</dt><dd>{{.D.Customer.GameName}}</dd></div>{{end}}
{{if .D.Customer.GameServer}}<div><dt>区服</dt><dd>{{.D.Customer.GameServer}}</dd></div>{{end}}
{{if .D.Customer.Contact}}<div><dt>联系</dt><dd>{{.D.Customer.Contact}}</dd></div>{{end}}
{{if .D.Customer.Source}}<div><dt>来源</dt><dd>{{.D.Customer.Source}}</dd></div>{{end}}
<div><dt>建档</dt><dd>{{.D.Customer.CreatedAt}}</dd></div>
</dl>
<div class="page-actions">
<a class="btn" href="/customers/{{.D.Customer.ID}}/edit">编辑资料</a>
<a class="btn btn-ghost" href="/customers">返回列表</a>
</div>
</header>
{{if .D.Customer.Note}}<div class="notice note">{{.D.Customer.Note}}</div>{{end}}
<section class="section">
<div class="section-head">
<h2>订阅</h2>
<span class="muted">同一套餐只能有一条订阅,需要更多时长请用「追加」</span>
</div>
{{if .D.Subs}}
<div class="subgrid">
{{range .D.Subs}}{{template "sub-card" .}}{{end}}
</div>
{{else}}
<p class="muted">还没有订阅。用下面的表单为他开通第一个套餐。</p>
{{end}}
{{if .D.Packages}}
<form method="post" action="/customers/{{.D.Customer.ID}}/subscriptions" class="inline-form" data-sub-form>
<select name="package_id" required data-pkg-select>
<option value="">选择套餐…</option>
{{range .D.Packages}}
<option value="{{.ID}}" data-unit="{{.Unit}}" data-amount="{{.DefaultAmount}}"{{if .Archived}} disabled{{end}}>
{{.Name}} · {{.UnitLabel}} {{.DefaultAmount}}{{if eq .Unit "day"}}天{{else}}次{{end}}{{if .Archived}}(已归档){{end}}
</option>
{{end}}
</select>
<label class="inline-field">开始日
<input type="date" name="start_date" value="{{.TodayStr}}" required>
</label>
<label class="inline-field" data-days-wrap>{{/* 默认显示 */}}
天数 <input type="number" name="days" min="1" max="3650" value="30">
</label>
<label class="inline-field" data-count-wrap hidden>
次数 <input type="number" name="total_count" min="1" max="10000" value="10">
</label>
<label class="inline-field" data-end-wrap>
终止日(可留空)<input type="date" name="end_date">
</label>
<button class="btn btn-primary" type="submit">开通订阅</button>
</form>
{{else}}
<p class="muted">还没有可用套餐,<a href="/packages/new">先新建套餐</a>。</p>
{{end}}
</section>
{{if .D.Extend}}{{template "extend-panel" .D.Extend}}{{end}}
{{if .D.Timeline}}
<section class="section">
<div class="section-head">
<h2>时间轴</h2>
<span class="muted">{{.D.Timeline.Start}} → {{.D.Timeline.End}} · 灰色为暂停段</span>
</div>
<div class="timeline">
<div class="tl-axis">
{{range .D.Timeline.Ticks}}<span class="tl-tick" style="left:{{pct .Left}}%">{{.Label}}</span>{{end}}
</div>
{{range .D.Timeline.Bars}}
<div class="tl-row">
<span class="tl-name">{{.Name}}</span>
<div class="tl-track">
<i class="tl-bar st-{{.State}}" style="--tag:{{.Color}};left:{{pct .Left}}%;width:{{pct .Width}}%"></i>
{{range .Pauses}}<u class="tl-pause" style="left:{{pct .Left}}%;width:{{pct .Width}}%"></u>{{end}}
{{if .HasDue}}<em class="tl-due" style="left:{{pct .DueLeft}}%"></em>{{end}}
</div>
<span class="tl-legend">{{.Label}}</span>
</div>
{{end}}
</div>
</section>
{{end}}
<section class="section">
<div class="section-head">
<h2>服务时长追加记录</h2>
<span class="muted">每次追加都留痕,可作为对账依据</span>
</div>
{{if .D.Adjustments}}
<div class="table-wrap">
<table class="table tight">
<thead>
<tr><th>时间</th><th>套餐</th><th>类型</th><th>计算起点</th><th>结束日变化</th><th>增量</th><th>备注</th><th>操作人</th></tr>
</thead>
<tbody>
{{range .D.Adjustments}}
<tr>
<td class="mono small">{{.CreatedAt}}</td>
<td><span class="dot" style="background:{{index $.D.AdjSubColor .SubID}}"></span>{{index $.D.AdjSubName .SubID}}</td>
<td>{{.KindLabel}}</td>
<td class="mono small">{{if .BaseDate}}{{.BaseDate}}{{else}}—{{end}}</td>
<td class="mono small">{{if .OldEndDate}}{{.OldEndDate}} → {{end}}{{if .NewEndDate}}{{.NewEndDate}}{{else}}—{{end}}</td>
<td class="mono">
{{if gt .DaysAdded 0}}<span class="plus">+{{.DaysAdded}} 天</span>{{end}}
{{if gt .CountAdded 0}}<span class="plus">+{{.CountAdded}} 次</span>{{end}}
{{if and (le .DaysAdded 0) (le .CountAdded 0)}}—{{end}}
</td>
<td class="small">{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
<td class="small">{{if .OperatorName}}{{.OperatorName}}{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}<p class="muted">还没有追加记录。</p>{{end}}
</section>
<section class="section">
<div class="section-head">
<h2>打卡历史</h2>
<span class="muted">最近 30 条</span>
</div>
{{if .D.Checkins}}
<ul class="checkin-list">
{{range .D.Checkins}}
<li>
<span class="mono">{{.ServiceDate}}</span>
<span class="dot" style="background:{{.SubColor}}"></span>
<span>{{.SubName}}</span>
<span class="chip {{if eq .Status "done"}}chip-active{{else}}chip-paused{{end}}">{{if eq .Status "done"}}完成{{else}}跳过{{end}}</span>
{{if .Note}}<span class="muted small">{{.Note}}</span>{{end}}
<span class="muted small">{{if .OperatorName}}{{.OperatorName}}{{end}}</span>
</li>
{{end}}
</ul>
{{else}}<p class="muted">还没有打卡记录。</p>{{end}}
</section>
</div>
{{end}}
@@ -0,0 +1,48 @@
{{define "content"}}
<div class="page narrow">
<header class="page-head">
<div>
<h1>{{if .D.IsNew}}新建客户{{else}}编辑客户{{end}}</h1>
<p class="muted">手机尾号 + 客户名 唯一;尾号是日常查询的主键。</p>
</div>
</header>
<form method="post" class="form card"
action="{{if .D.IsNew}}/customers{{else}}/customers/{{.D.Customer.ID}}/edit{{end}}">
<div class="grid-2">
<label class="field"><span>手机尾号 <i>*</i></span>
<input name="tail" value="{{.D.Customer.Tail}}" required maxlength="8" placeholder="例如 1234" autofocus>
</label>
<label class="field"><span>客户名 <i>*</i></span>
<input name="name" value="{{.D.Customer.Name}}" required maxlength="32" placeholder="例如 老张">
</label>
</div>
<div class="grid-2">
<label class="field"><span>游戏名</span>
<input name="game_name" value="{{.D.Customer.GameName}}" maxlength="32" placeholder="例如 王者荣耀">
</label>
<label class="field"><span>区服 / UID</span>
<input name="game_server" value="{{.D.Customer.GameServer}}" maxlength="32" placeholder="例如 微信区 123456">
</label>
</div>
<div class="grid-2">
<label class="field"><span>联系方式</span>
<input name="contact" value="{{.D.Customer.Contact}}" maxlength="64" placeholder="微信 / QQ">
</label>
<label class="field"><span>来源渠道</span>
<input name="source" value="{{.D.Customer.Source}}" maxlength="32" placeholder="抖音 / 朋友介绍">
</label>
</div>
<label class="field"><span>标签</span>
<input name="tags" value="{{.D.Customer.Tags}}" maxlength="120" placeholder="老客户,包月,难缠(逗号分隔)">
</label>
<label class="field"><span>备注</span>
<textarea name="note" rows="3" maxlength="500" placeholder="打法偏好、沟通记录等">{{.D.Customer.Note}}</textarea>
</label>
<div class="actions">
<button class="btn btn-primary" type="submit">保存</button>
<a class="btn" href="{{if .D.IsNew}}/customers{{else}}/customers/{{.D.Customer.ID}}{{end}}">取消</a>
</div>
</form>
</div>
{{end}}
@@ -0,0 +1,58 @@
{{define "content"}}
<div class="page">
<header class="page-head">
<div>
<h1>客户</h1>
<p class="muted">共 {{.D.Total}} 位 · 输入手机尾号可快速定位</p>
</div>
<div class="page-actions">
<a class="btn btn-primary" href="/customers/new">+ 新建客户</a>
</div>
</header>
<form class="filterbar" method="get" action="/customers">
<input type="search" name="q" value="{{.D.Q}}" placeholder="手机尾号 / 姓名 / 游戏名 / 联系方式" autocomplete="off">
<button class="btn" type="submit">搜索</button>
{{if .D.Q}}<a class="btn btn-ghost" href="/customers">清除</a>{{end}}
</form>
{{if .D.List}}
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>尾号</th><th>客户</th><th>联系方式</th><th>订阅与剩余</th><th>标签</th><th></th></tr>
</thead>
<tbody>
{{range .D.List}}
<tr>
<td><a class="tail" href="/customers/{{.ID}}">{{.Tail}}</a></td>
<td>
<a class="name" href="/customers/{{.ID}}">{{.Name}}</a>
{{if .GameName}}<div class="muted small">{{.GameName}}{{if .GameServer}} · {{.GameServer}}{{end}}</div>{{end}}
</td>
<td class="muted small">{{if .Contact}}{{.Contact}}{{else}}—{{end}}</td>
<td>
<span class="summary due-{{.Level}}">{{.Summary}}</span>
</td>
<td>{{range .TagList}}<span class="tagchip">{{.}}</span>{{end}}</td>
<td class="right"><a class="btn btn-sm" href="/customers/{{.ID}}">详情</a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pager" .D.Pager}}
{{else}}
<div class="empty">
<div class="empty-mark">票</div>
{{if .D.Q}}
<p>没有匹配「{{.D.Q}}」的客户。</p>
<a class="btn" href="/customers">看全部客户</a>
{{else}}
<p>还没有客户。先建一位,再给他开套餐。</p>
<a class="btn btn-primary" href="/customers/new">+ 新建客户</a>
{{end}}
</div>
{{end}}
</div>
{{end}}
+20
View File
@@ -0,0 +1,20 @@
{{define "content"}}
<div class="auth">
<div class="auth-card">
<div class="auth-brand"><span class="brand-mark">代</span><span>代打台</span></div>
<h1>登录</h1>
<p class="muted">所有账户均为管理员,任何操作都会留痕。</p>
<form method="post" action="/login" class="form">
<input type="hidden" name="next" value="{{.Next}}">
<label class="field"><span>用户名</span>
<input type="text" name="username" required autofocus autocomplete="username">
</label>
<label class="field"><span>密码</span>
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit" class="btn btn-primary btn-block">登录</button>
</form>
<p class="auth-foot muted">代打台 · 单文件部署 · 数据只在本机</p>
</div>
</div>
{{end}}
+39
View File
@@ -0,0 +1,39 @@
{{define "content"}}
<div class="page">
<header class="page-head">
<div>
<h1>操作日志</h1>
<p class="muted">共 {{.D.Total}} 条 · 谁在什么时候改了什么,都在这里</p>
</div>
</header>
<form class="filterbar" method="get" action="/logs">
<input type="search" name="q" value="{{.D.Q}}" placeholder="搜索摘要 / 动作 / 操作人" autocomplete="off">
<button class="btn" type="submit">搜索</button>
{{if .D.Q}}<a class="btn btn-ghost" href="/logs">清除</a>{{end}}
</form>
{{if .D.List}}
<div class="table-wrap">
<table class="table tight">
<thead><tr><th>时间</th><th>操作人</th><th>动作</th><th>对象</th><th>摘要</th><th>IP</th></tr></thead>
<tbody>
{{range .D.List}}
<tr>
<td class="mono small">{{.CreatedAt}}</td>
<td>{{if .OperatorName}}{{.OperatorName}}{{else}}系统{{end}}</td>
<td><span class="chip chip-plain">{{.Action}}</span></td>
<td class="small muted">{{if .Entity}}{{.Entity}}{{if .EntityID}} #{{.EntityID}}{{end}}{{else}}—{{end}}</td>
<td class="small">{{.Summary}}</td>
<td class="mono small muted">{{.IP}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pager" .D.Pager}}
{{else}}
<div class="empty"><div class="empty-mark">票</div><p>没有符合条件的日志。</p></div>
{{end}}
</div>
{{end}}
@@ -0,0 +1,57 @@
{{define "content"}}
<div class="page narrow">
<header class="page-head">
<div>
<h1>{{if .D.IsNew}}新增套餐{{else}}编辑套餐{{end}}</h1>
<p class="muted">按日套餐用起止日期计算剩余天数;按次套餐记录剩余次数。</p>
</div>
</header>
<form method="post" class="form card" data-package-form
action="{{if .D.IsNew}}/packages{{else}}/packages/{{.D.Package.ID}}/edit{{end}}">
<label class="field"><span>套餐名称 <i>*</i></span>
<input name="name" required maxlength="32" value="{{.D.Package.Name}}" placeholder="例如 包月日常 / 赛季深渊 / 10 次副本">
</label>
<fieldset class="field">
<span>计费方式 <i>*</i></span>
<div class="radios">
<label><input type="radio" name="unit" value="day"{{if eq .D.Package.Unit "day"}} checked{{end}}>
<b>按日</b><small>有开始日与结束日,每天一条待打任务,可打卡</small></label>
<label><input type="radio" name="unit" value="count"{{if eq .D.Package.Unit "count"}} checked{{end}}>
<b>按次</b><small>记录剩余次数,排期完成时自动扣 1 次,有效期可选</small></label>
</div>
</fieldset>
<div class="grid-2">
<label class="field"><span>默认量 <i>*</i></span>
<div class="input-suffix">
<input type="number" name="default_amount" min="1" max="10000" value="{{.D.Package.DefaultAmount}}" required>
<b data-unit-label>{{if eq .D.Package.Unit "count"}}次{{else}}天{{end}}</b>
</div>
</label>
<label class="field"><span>参考价(元,可空)</span>
<input type="number" name="price" min="0" step="1" value="{{if .D.Package.HasPrice}}{{money .D.Package.PriceCents}}{{end}}" placeholder="例如 300">
</label>
</div>
<div class="grid-2">
<label class="field"><span>日历色签</span>
<input type="color" name="color" value="{{.D.Package.Color}}">
</label>
<label class="field"><span>排序(小的靠前)</span>
<input type="number" name="sort_order" value="{{.D.Package.SortOrder}}">
</label>
</div>
<label class="field"><span>说明</span>
<textarea name="note" rows="2" maxlength="200" placeholder="套餐包含什么、怎么个打法">{{.D.Package.Note}}</textarea>
</label>
<div class="actions">
<button class="btn btn-primary" type="submit">保存</button>
<a class="btn" href="/packages">取消</a>
</div>
</form>
</div>
{{end}}
@@ -0,0 +1,49 @@
{{define "content"}}
<div class="page">
<header class="page-head">
<div>
<h1>套餐</h1>
<p class="muted">新增套餐只需加一行配置,不会影响任何已有客户与订阅</p>
</div>
<div class="page-actions"><a class="btn btn-primary" href="/packages/new">+ 新增套餐</a></div>
</header>
{{if .D.List}}
<div class="table-wrap">
<table class="table">
<thead>
<tr><th class="col-dot"></th><th>名称</th><th>类型</th><th>默认量</th><th>参考价</th><th>订阅中 / 总数</th><th>状态</th><th></th></tr>
</thead>
<tbody>
{{range .D.List}}
<tr{{if .Archived}} class="archived"{{end}}>
<td><span class="dot big" style="background:{{.Color}}"></span></td>
<td>
<b>{{.Name}}</b>
{{if .Note}}<div class="muted small">{{.Note}}</div>{{end}}
</td>
<td>{{.UnitLabel}}</td>
<td class="mono">{{.DefaultAmount}}{{if eq .Unit "day"}} 天{{else}} 次{{end}}</td>
<td class="mono">{{if .HasPrice}}¥{{money .PriceCents}}{{else}}—{{end}}</td>
<td class="mono">{{.ActiveCount}} / {{.SubCount}}</td>
<td>{{if .Archived}}<span class="chip chip-closed">已归档</span>{{else}}<span class="chip chip-active">启用中</span>{{end}}</td>
<td class="actions-cell">
<a class="btn btn-sm" href="/packages/{{.ID}}/edit">编辑</a>
<form method="post" action="/packages/{{.ID}}/archive"{{if not .Archived}} data-confirm="归档后不能再新建该套餐的订阅,已有订阅不受影响。继续?"{{end}}>
<button class="btn btn-sm btn-ghost">{{if .Archived}}恢复{{else}}归档{{end}}</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty">
<div class="empty-mark">票</div>
<p>还没有套餐。先建「包月日常」这类常用套餐,之后客户开通就一键完成。</p>
<a class="btn btn-primary" href="/packages/new">+ 新增套餐</a>
</div>
{{end}}
</div>
{{end}}
+113
View File
@@ -0,0 +1,113 @@
{{define "content"}}
<div class="scr">
<header class="scr-head">
<div class="scr-brand"><span class="mark">代</span>代打台<em>控制台</em></div>
<div class="scr-clock"><b data-clock>--:--:--</b><span data-date></span></div>
<div class="scr-links">每 {{.D.Refresh}} 秒刷新 · <a href="/calendar">进入工作台</a></div>
</header>
<section class="scr-kpis">
<div class="kpi">
<span>客户</span><b>{{.D.Stats.Customers}}</b><i>位</i>
</div>
<div class="kpi">
<span>活跃订阅</span><b>{{.D.Stats.ActiveSubs}}</b><i>条</i>
</div>
<div class="kpi kpi-hot">
<span>今日到期</span><b>{{.D.Stats.DueToday}}</b><i>条</i>
</div>
<div class="kpi kpi-warm">
<span>7 天内到期</span><b>{{.D.Stats.Due7}}</b><i>条</i>
</div>
</section>
<section class="scr-main">
<div class="panel panel-cal">
<div class="panel-head">
<h2>到期热力</h2>
<span>{{.D.MonthTitle}} · 本月 {{.D.Stats.MonthDue}} 条到期</span>
</div>
<div class="heat-grid">
<div class="heat-dow">{{range .D.Weekdays}}<span>{{.}}</span>{{end}}</div>
{{range .D.Weeks}}
<div class="heat-row">
{{range .}}
<div class="heat-cell h{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}">
<span>{{.Day}}</span>
{{if .Due}}<b>{{.Due}}</b>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="heat-legend">
<span>到期数</span><i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i>
</div>
</div>
<div class="panel panel-top">
<div class="panel-head"><h2>即将到期 TOP 10</h2><span>按剩余量排序</span></div>
{{if .D.Top}}
<ol class="top-list">
{{range $i, $s := .D.Top}}
<li class="top-item lv-{{$s.Eval.Level}}">
<span class="rank">{{add $i 1}}</span>
<span class="top-tail">{{$s.CustomerTail}}</span>
<span class="top-name">{{$s.CustomerName}}</span>
<span class="top-pkg" style="--tag:{{$s.PackageColor}}">{{$s.PackageName}}</span>
<span class="top-remain">{{$s.RemainShort}}</span>
<span class="top-bar"><i style="width:{{$s.Progress}}%"></i></span>
</li>
{{end}}
</ol>
{{else}}
<p class="scr-empty">暂无订阅数据</p>
{{end}}
</div>
</section>
<section class="scr-bottom">
<div class="panel">
<div class="panel-head">
<h2>今日待打</h2>
<span>{{.D.TodayDone}} / {{.D.TodayTasks}} 已完成</span>
</div>
{{if .D.TodayList}}
<ul class="today-list">
{{range .D.TodayList}}
<li class="lv-{{.Eval.Level}}">
<span class="tchip" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
<span class="rem">{{.RemainShort}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="scr-empty">今天没有待打任务</p>
{{end}}
</div>
<div class="panel">
<div class="panel-head"><h2>最近追加</h2><span>服务时长变更流水</span></div>
{{if .D.Recent}}
<ul class="recent-list">
{{range .D.Recent}}
<li>
<span class="time mono">{{.CreatedAt}}</span>
<span class="who"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
{{if gt .DaysAdded 0}}<em class="plus">+{{.DaysAdded}} 天</em>{{end}}
{{if gt .CountAdded 0}}<em class="plus">+{{.CountAdded}} 次</em>{{end}}
<span class="op">{{.OperatorName}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="scr-empty">还没有追加记录</p>
{{end}}
</div>
</section>
<footer class="scr-foot">代打台 v{{.D.Version}} · 数据每 {{.D.Refresh}} 秒自动刷新 · 按 F 进入全屏</footer>
</div>
{{end}}
+102
View File
@@ -0,0 +1,102 @@
{{define "content"}}
<div class="page narrow">
<header class="page-head">
<div>
<h1>设置</h1>
<p class="muted">预警天数、备份、账户与数据导出</p>
</div>
</header>
<section class="section card">
<h2>基础设置</h2>
<form method="post" action="/settings" class="form">
<div class="grid-2">
<label class="field"><span>到期预警(天)</span>
<input type="number" name="warn_days" min="0" max="60" value="{{.D.WarnDays}}">
</label>
<label class="field"><span>大屏自动刷新(秒)</span>
<input type="number" name="screen_refresh" min="5" max="600" value="{{.D.ScreenRefresh}}">
</label>
</div>
<p class="muted small">监听地址、端口、数据库、备份、日志、时区等部署参数在 <code>daili.ini</code> 里配置,改完重启生效。</p>
<div class="actions"><button class="btn btn-primary" type="submit">保存设置</button></div>
</form>
</section>
<section class="section card">
<h2>管理员</h2>
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。</p>
<div class="table-wrap">
<table class="table tight">
<thead><tr><th>用户名</th><th>显示名称</th><th>状态</th><th>创建时间</th><th>最近登录</th><th></th></tr></thead>
<tbody>
{{$me := .User}}
{{range .D.Users}}
<tr>
<td class="mono">{{.Username}}</td>
<td>{{.DisplayName}}</td>
<td>{{if .IsActive}}<span class="chip chip-active">启用</span>{{else}}<span class="chip chip-closed">停用</span>{{end}}</td>
<td class="mono small">{{.CreatedAt}}</td>
<td class="mono small">{{if .LastLoginAt}}{{.LastLoginAt}}{{else}}—{{end}}</td>
<td class="actions-cell">
{{if eq .ID $me.ID}}
<span class="muted small">当前账户</span>
{{else}}
<form method="post" action="/settings/users/{{.ID}}/toggle">
<button class="btn btn-sm btn-ghost">{{if .IsActive}}停用{{else}}启用{{end}}</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<form method="post" action="/settings/users" class="inline-form">
<label class="inline-field">用户名 <input name="username" required minlength="3" maxlength="32" autocomplete="off"></label>
<label class="inline-field">显示名称 <input name="display_name" maxlength="32"></label>
<label class="inline-field">初始密码 <input type="password" name="password" required minlength="6" autocomplete="new-password"></label>
<button class="btn btn-primary" type="submit">新增管理员</button>
</form>
</section>
<section class="section card">
<h2>修改我的密码</h2>
<form method="post" action="/settings/password" class="form">
<div class="grid-3">
<label class="field"><span>原密码</span><input type="password" name="old_password" required autocomplete="current-password"></label>
<label class="field"><span>新密码(≥6 位)</span><input type="password" name="password" required minlength="6" autocomplete="new-password"></label>
<label class="field"><span>再输一次</span><input type="password" name="password2" required minlength="6" autocomplete="new-password"></label>
</div>
<div class="actions"><button class="btn btn-primary" type="submit">更新密码</button></div>
</form>
</section>
<section class="section card">
<h2>数据</h2>
<dl class="kv">
<div><dt>配置文件</dt><dd class="mono small">{{.D.ConfigPath}} <span class="muted">(修改后重启生效)</span></dd></div>
<div><dt>数据库</dt><dd class="mono small">{{.D.DBPath}}</dd></div>
<div><dt>备份</dt><dd class="mono small">{{.D.BackupDir}} · 保留 {{.D.BackupKeep}} 份 · {{if .D.BackupEnabled}}自动备份已开启{{else}}自动备份已关闭{{end}}</dd></div>
<div><dt>大屏</dt><dd class="mono small">/screen {{if .D.ScreenPublic}}免登录{{else}}需登录{{end}}</dd></div>
<div><dt>局域网地址</dt><dd class="mono small">http://{{if .D.Addrs}}{{index .D.Addrs 0}}{{else}}127.0.0.1{{end}}:{{.D.Port}}/screen</dd></div>
<div><dt>已运行</dt><dd class="mono">{{.D.Uptime}}</dd></div>
<div><dt>数据量</dt><dd>{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志</dd></div>
</dl>
<div class="actions wrap">
<form method="post" action="/settings/backup"><button class="btn" type="submit">立即备份</button></form>
<a class="btn" href="/export/customers.csv">导出客户 CSV</a>
<a class="btn" href="/export/subscriptions.csv">导出订阅 CSV</a>
<a class="btn" href="/export/logs.csv">导出日志 CSV</a>
</div>
{{if .D.Backups}}
<details class="backups">
<summary>最近备份({{len .D.Backups}} 份)</summary>
<ul class="mono small">
{{range .D.Backups}}<li>{{.}}</li>{{end}}
</ul>
</details>
{{end}}
</section>
</div>
{{end}}
+25
View File
@@ -0,0 +1,25 @@
{{define "content"}}
<div class="auth">
<div class="auth-card">
<div class="auth-brand"><span class="brand-mark">代</span><span>代打台</span></div>
<h1>创建第一个管理员</h1>
<p class="muted">数据库里还没有账户。设置好账号后即可开始录入套餐与客户。</p>
<form method="post" action="/setup" class="form">
<label class="field"><span>用户名</span>
<input type="text" name="username" required minlength="3" maxlength="32" autocomplete="username" placeholder="建议用拼音或英文名">
</label>
<label class="field"><span>显示名称</span>
<input type="text" name="display_name" maxlength="32" placeholder="例如:老王(可留空)">
</label>
<label class="field"><span>密码(至少 6 位)</span>
<input type="password" name="password" required minlength="6" autocomplete="new-password">
</label>
<label class="field"><span>再输一次密码</span>
<input type="password" name="password2" required minlength="6" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary btn-block">创建并登录</button>
</form>
<p class="auth-foot muted">忘记密码只能手动改数据库,请妥善保管。</p>
</div>
</div>
{{end}}
@@ -0,0 +1,76 @@
{{define "content"}}
<div class="page">
<header class="page-head">
<div>
<h1>订阅</h1>
<p class="muted">共 {{.D.Total}} 条 · 剩余时长各自独立计算</p>
</div>
</header>
<div class="tabs">
<a href="/subscriptions?state=active" class="{{if eq .D.State "active"}}on{{end}}">进行中</a>
<a href="/subscriptions?state=due7" class="{{if eq .D.State "due7"}}on{{end}}">7 天内到期</a>
<a href="/subscriptions?state=due_today" class="{{if eq .D.State "due_today"}}on{{end}}">今天到期</a>
<a href="/subscriptions?state=expired" class="{{if eq .D.State "expired"}}on{{end}}">已到期</a>
<a href="/subscriptions?state=paused" class="{{if eq .D.State "paused"}}on{{end}}">暂停中</a>
<a href="/subscriptions?state=closed" class="{{if eq .D.State "closed"}}on{{end}}">已结束</a>
<a href="/subscriptions?state=all" class="{{if eq .D.State "all"}}on{{end}}">全部</a>
</div>
<form class="filterbar" method="get" action="/subscriptions">
<input type="hidden" name="state" value="{{.D.State}}">
<input type="search" name="q" value="{{.D.Q}}" placeholder="尾号 / 姓名 / 套餐" autocomplete="off">
<select name="package_id">
<option value="0">全部套餐</option>
{{range .D.Packages}}
<option value="{{.ID}}"{{if eq $.D.PackageID .ID}} selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<button class="btn" type="submit">筛选</button>
</form>
{{if .D.List}}
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>客户</th><th>套餐</th><th>状态</th><th>剩余</th><th>时间</th><th>操作</th></tr>
</thead>
<tbody>
{{range .D.List}}
<tr>
<td>
<a class="tail" href="/customers/{{.CustomerID}}">{{.CustomerTail}}</a>
<a class="name" href="/customers/{{.CustomerID}}">{{.CustomerName}}</a>
</td>
<td><span class="dot" style="background:{{.PackageColor}}"></span>{{.PackageName}}
<span class="muted small">{{.UnitLabel}}</span></td>
<td>{{template "state-chip" .}}</td>
<td class="mono">{{.RemainText}}{{if .DueText}}<div class="due due-{{.Eval.Level}} small">{{.DueText}}</div>{{end}}</td>
<td class="mono small">{{.PeriodText}}</td>
<td class="actions-cell">
{{if ne .Status "closed"}}
<a class="btn btn-sm" href="/customers/{{.CustomerID}}?extend={{.ID}}#extend">追加</a>
{{if eq .Status "active"}}
<form method="post" action="/subscriptions/{{.ID}}/pause"><button class="btn btn-sm btn-ghost">暂停</button></form>
{{else if eq .Status "paused"}}
<form method="post" action="/subscriptions/{{.ID}}/resume"><button class="btn btn-sm">恢复</button></form>
{{end}}
{{else}}
<form method="post" action="/subscriptions/{{.ID}}/reopen"><button class="btn btn-sm">重新启用</button></form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pager" .D.Pager}}
{{else}}
<div class="empty">
<div class="empty-mark">票</div>
<p>这个筛选下没有订阅。</p>
<a class="btn" href="/subscriptions?state=all">看全部</a>
</div>
{{end}}
</div>
{{end}}
+424
View File
@@ -0,0 +1,424 @@
package web
import (
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"time"
"daili/internal/domain"
"daili/internal/store"
)
// SubView 是订阅的展示视图(已算好状态、文案与进度)。
type SubView struct {
store.Subscription
Eval domain.Eval
StateLabel string
StateClass string
RemainText string
RemainShort string
PeriodText string
DueText string
Progress int
Checkin *store.Checkin
CheckinState string
UnitLabel string
Today string
// CanCheckinToday 表示今天可以直接打卡(订阅在有效状态与区间内)
CanCheckinToday bool
}
func stateLabel(state string) (string, string) {
switch state {
case domain.StateActive:
return "进行中", "active"
case domain.StatePending:
return "未开始", "pending"
case domain.StatePaused:
return "暂停中", "paused"
case domain.StateExpired:
return "已到期", "expired"
case domain.StateUsedUp:
return "次数用尽", "usedup"
case domain.StateClosed:
return "已结束", "closed"
}
return "未知", "unknown"
}
func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
e := s.Eval(today, warnDays)
label, class := stateLabel(e.State)
v := SubView{
Subscription: s,
Eval: e,
StateLabel: label,
StateClass: class,
Today: domain.Date(today),
}
if s.Unit == domain.UnitDay {
v.UnitLabel = "按日"
v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays)
v.RemainShort = fmt.Sprintf("%d 天", e.RemainingDays)
v.PeriodText = s.StartDate + " → " + s.EndDate
total := domain.DayDiff(domain.ParseDateOr(s.StartDate, today), domain.ParseDateOr(s.EndDate, today)) + 1
if total > 0 {
v.Progress = clampPct(e.RemainingDays * 100 / total)
}
} else {
v.UnitLabel = "按次"
v.RemainText = fmt.Sprintf("剩 %d / %d 次", e.RemainingCount, s.TotalCount)
v.RemainShort = fmt.Sprintf("%d 次", e.RemainingCount)
v.PeriodText = s.StartDate + " 起"
if s.EndDate != "" {
v.PeriodText += " · 有效期至 " + s.EndDate
} else {
v.PeriodText += " · 无有效期"
}
if s.TotalCount > 0 {
v.Progress = clampPct(e.RemainingCount * 100 / s.TotalCount)
}
}
switch e.Level {
case domain.LevelToday:
v.DueText = "今天到期"
case domain.LevelWarn:
switch {
case e.DaysLeft == 1:
v.DueText = "明天到期"
case e.DaysLeft > 1:
v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft)
case s.Unit == domain.UnitDay:
v.DueText = fmt.Sprintf("还剩 %d 天", e.RemainingDays)
default:
v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft)
}
case domain.LevelExpired:
if s.EndDate != "" {
d := domain.DayDiff(domain.ParseDateOr(s.EndDate, today), today)
if d > 0 {
v.DueText = fmt.Sprintf("已过期 %d 天", d)
} else {
v.DueText = "已到期"
}
} else {
v.DueText = "次数已用尽"
}
}
// 今天能否打卡:进行中且(按日或有剩余次数)
v.CanCheckinToday = e.State == domain.StateActive &&
(s.Unit == domain.UnitDay || e.RemainingCount > 0)
return v
}
func clampPct(v int) int {
if v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}
func subViews(subs []store.Subscription, today time.Time, warnDays int) []SubView {
out := make([]SubView, 0, len(subs))
for _, s := range subs {
out = append(out, newSubView(s, today, warnDays))
}
return out
}
// ---------- 追加面板 ----------
// ExtendPanel 是「追加时长/次数」面板数据。
type ExtendPanel struct {
Sub store.Subscription
View SubView
IsDay bool
Today string
OldEnd string
Base string
Suggested string
SuggestedDays int
Back string
Months []MiniMonth
DefaultAmount int
}
// MiniMonth 是面板内的迷你月历。
type MiniMonth struct {
Title string
Weeks [][]MiniDay
}
// MiniDay 是迷你月历的一天。
type MiniDay struct {
Date string
Day int
InMonth bool
IsOldEnd bool
IsToday bool
Disabled bool
IsWeekend bool
}
func buildExtendPanel(s *store.Subscription, pkg *store.Package, today time.Time, back string, warnDays int) *ExtendPanel {
p := &ExtendPanel{
Sub: *s,
View: newSubView(*s, today, warnDays),
IsDay: s.Unit == domain.UnitDay,
Today: domain.Date(today),
Back: back,
DefaultAmount: pkg.DefaultAmount,
}
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if s.Unit != domain.UnitDay {
return p
}
oldEnd := domain.ParseDateOr(s.EndDate, today)
plan := domain.PlanExtend(oldEnd, oldEnd, today)
suggested := domain.AddDays(plan.Base, p.DefaultAmount-1)
p.OldEnd = domain.Date(oldEnd)
p.Base = domain.Date(plan.Base)
p.Suggested = domain.Date(suggested)
p.SuggestedDays = p.DefaultAmount
anchor := oldEnd
if anchor.Before(today) {
anchor = today
}
sy, sm := anchor.Year(), anchor.Month()
for i := 0; i < 3; i++ {
mm := time.Month(int(sm) + i)
y := sy
for mm > 12 {
mm -= 12
y++
}
p.Months = append(p.Months, buildMiniMonth(y, mm, plan.Base, oldEnd, today))
}
return p
}
func buildMiniMonth(y int, m time.Month, base, oldEnd, today time.Time) MiniMonth {
first := domain.MonthStart(y, m)
last := domain.MonthEnd(y, m)
start := domain.AddDays(first, -domain.WeekdayIndex(first))
end := domain.AddDays(last, 7-domain.WeekdayIndex(last)-1)
if domain.WeekdayIndex(last) == 6 {
end = last
}
mm := MiniMonth{Title: fmt.Sprintf("%d 年 %d 月", y, int(m))}
var week []MiniDay
for d := start; !d.After(end); d = domain.AddDays(d, 1) {
md := MiniDay{
Date: domain.Date(d),
Day: d.Day(),
InMonth: d.Month() == m,
IsOldEnd: domain.Date(d) == domain.Date(oldEnd),
IsToday: domain.Date(d) == domain.Date(today),
Disabled: d.Before(base),
IsWeekend: d.Weekday() == time.Saturday || d.Weekday() == time.Sunday,
}
week = append(week, md)
if len(week) == 7 {
mm.Weeks = append(mm.Weeks, week)
week = nil
}
}
if len(week) > 0 {
mm.Weeks = append(mm.Weeks, week)
}
return mm
}
// ---------- 时间轴 ----------
// Timeline 是客户订阅的时间轴(签带)。
type Timeline struct {
Start string
End string
Ticks []Tick
Bars []Bar
}
// Tick 是时间轴刻度。
type Tick struct {
Label string
Left float64
}
// Bar 是一条订阅签带。
type Bar struct {
SubID int64
Name string
Color string
Left float64
Width float64
DueLeft float64
Label string
State string
Pauses []BarPause
HasDue bool
}
// BarPause 是签带上的暂停灰段。
type BarPause struct {
Left float64
Width float64
}
func buildTimeline(subs []store.Subscription, pauses map[int64][]store.Pause, today time.Time) *Timeline {
if len(subs) == 0 {
return nil
}
start := today
end := today
for _, s := range subs {
st := domain.ParseDateOr(s.StartDate, today)
if st.Before(start) {
start = st
}
en := st.AddDate(0, 0, 30)
if s.EndDate != "" {
en = domain.ParseDateOr(s.EndDate, en)
}
if en.After(end) {
end = en
}
}
start = domain.AddDays(start, -7)
end = domain.AddDays(end, 14)
totalDays := domain.DayDiff(start, end) + 1
if totalDays <= 0 {
return nil
}
pos := func(t time.Time) float64 {
return float64(domain.DayDiff(start, t)) / float64(totalDays) * 100
}
tl := &Timeline{Start: domain.Date(start), End: domain.Date(end)}
// 月刻度
cur := domain.MonthStart(start.Year(), start.Month())
if cur.Before(start) {
cur = cur.AddDate(0, 1, 0)
}
for !cur.After(end) {
tl.Ticks = append(tl.Ticks, Tick{
Label: fmt.Sprintf("%d.%02d", cur.Year(), int(cur.Month())),
Left: pos(cur),
})
cur = cur.AddDate(0, 1, 0)
}
for _, s := range subs {
st := domain.ParseDateOr(s.StartDate, today)
en := st.AddDate(0, 0, 30)
if s.EndDate != "" {
en = domain.ParseDateOr(s.EndDate, en)
}
if en.Before(st) {
en = st
}
b := Bar{
SubID: s.ID,
Name: s.PackageName,
Color: s.PackageColor,
Left: pos(st),
Width: float64(domain.DayDiff(st, en)+1) / float64(totalDays) * 100,
Label: domain.Date(st) + " → " + domain.Date(en),
State: s.Status,
HasDue: s.EndDate != "",
}
b.DueLeft = pos(en)
if b.Width < 0.8 {
b.Width = 0.8
}
for _, p := range pauses[s.ID] {
ps := domain.ParseDateOr(p.StartDate, st)
var pe time.Time
if p.EndDate != "" {
pe = domain.ParseDateOr(p.EndDate, today)
} else {
pe = today
}
if pe.Before(ps) {
pe = ps
}
if ps.Before(st) {
ps = st
}
if pe.After(en) {
pe = en
}
if pe.Before(ps) {
continue
}
b.Pauses = append(b.Pauses, BarPause{
Left: pos(ps),
Width: float64(domain.DayDiff(ps, pe)+1) / float64(totalDays) * 100,
})
}
tl.Bars = append(tl.Bars, b)
}
sort.Slice(tl.Bars, func(i, j int) bool { return tl.Bars[i].SubID < tl.Bars[j].SubID })
return tl
}
// ---------- 分页 ----------
// Pager 是分页控件数据。
type Pager struct {
Page int
Pages int
Total int
Prev string
Next string
From int
To int
}
func newPager(r *http.Request, pageNo, perPage, total int) Pager {
pages := (total + perPage - 1) / perPage
if pages < 1 {
pages = 1
}
if pageNo < 1 {
pageNo = 1
}
if pageNo > pages {
pageNo = pages
}
mk := func(n int) string {
q := url.Values{}
for k, v := range r.URL.Query() {
q[k] = v
}
q.Set("page", strconv.Itoa(n))
return r.URL.Path + "?" + q.Encode()
}
p := Pager{Page: pageNo, Pages: pages, Total: total}
p.From = (pageNo-1)*perPage + 1
if total == 0 {
p.From = 0
}
p.To = pageNo * perPage
if p.To > total {
p.To = total
}
if pageNo > 1 {
p.Prev = mk(pageNo - 1)
}
if pageNo < pages {
p.Next = mk(pageNo + 1)
}
return p
}