@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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("覆盖后的非法端口应被拦截")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬e, &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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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))+" 行)")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "已撤销打卡")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -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 |
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user