fix(v1.11.3): 客户自助查询 /customer 全量修复
build / build-and-test (push) Successful in 3m18s

- 路由:从 screen_public 拆出 portal_public(默认 true),关掉大屏不再连带 404;
  设置页显示客户自助状态;/customer 与 /screen 各自独立。
- 查询:尾号只接受 4–8 位数字,服务端精确匹配,去掉 LIKE 模糊回退;按 IP
  40 次/分钟限流;页面 noindex。
- 语义:修正「今日待完成」把已完成数当待完成数的问题;0/0 改为「今日无排单」;
  无订阅走空态;删掉进度条和重复的今日文案,只留一个状态。
- 订阅:复用管理端 SubView / calSub;通行证 1/1、深渊 x/y 周期;过期、暂停、
  按次用尽、跳过都有独立口径;「最近到期」只取未来日期。
- 日历:与主日历一致,只在本月格显示任务;通行证/深渊整段色块(深渊按周期
  减淡),日常任务用空心/实心点;每格带读屏描述;today 加「今天」文字标记。
- 错误:只渲染一次并放回内容容器,字段加 aria-invalid;删掉顶部重复横幅。
- 多客户:显示掩码姓名 + 卡内补姓名表单;姓名回填;翻月用 url.Values 编码;
  1 位尾号不触发查询;只有空态/补姓名时 autofocus。
- UI:品牌头、可折叠「查询其他尾号」、订阅全宽列表、状态统一用现有 chip、
  月份按钮 44px、去掉卡中卡 / opacity / 满屏 0/1。
- 版本 1.11.3;README 增加 4.4 与 portal_public 配置说明。

验证:gofmt / go vet / go test 全绿;portal_public=true 时 /customer 200、
false 时 404;screen_public=false + portal_public=true 时 /customer 200、
/screen 302;尾号 2 与 % 被拒;2222 多客户掩码;9900 日常点、3321 通行证
1/1、6677 深渊 1/4 + 已到期订阅;明暗 1440 与 390 截图无对比度/溢出问题。
This commit is contained in:
2026-10-20 03:39:41 +08:00
parent 7371b86a97
commit ddc7988f34
11 changed files with 539 additions and 197 deletions
+12 -2
View File
@@ -19,7 +19,7 @@
┌───────────────────────────────────────────────┐ ┌───────────────────────────────────────────────┐
│ 代 打 台 · 代打管理平台 │ │ 代 打 台 · 代打管理平台 │
└───────────────────────────────────────────────┘ └───────────────────────────────────────────────┘
版本 1.11.2 配置文件 daili.ini 版本 1.11.3 配置文件 daili.ini
数据库 D:\代打台\daili.db 数据库 D:\代打台\daili.db
访问地址: 访问地址:
本机 http://127.0.0.1:17777 本机 http://127.0.0.1:17777
@@ -49,6 +49,7 @@
| `timezone` | 空 | 留空跟随系统;可固定 `Asia/Shanghai`。**影响所有“自然日”计算** | | `timezone` | 空 | 留空跟随系统;可固定 `Asia/Shanghai`。**影响所有“自然日”计算** |
| `open_browser` | `true` | 启动后自动打开浏览器 | | `open_browser` | `true` | 启动后自动打开浏览器 |
| `screen_public` | `true` | 大屏 `/screen` 免登录(投屏用);改 `false` 则需登录 | | `screen_public` | `true` | 大屏 `/screen` 免登录(投屏用);改 `false` 则需登录 |
| `portal_public` | `true` | 客户自助查询 `/customer` 免登录;改 `false` 则关闭该页(不影响大屏) |
| `log_enabled` | `true` | 运行日志写文件(控制台始终输出) | | `log_enabled` | `true` | 运行日志写文件(控制台始终输出) |
| `log_dir` | `logs` | 日志目录,按天切分 `daili-YYYY-MM-DD.log` | | `log_dir` | `logs` | 日志目录,按天切分 `daili-YYYY-MM-DD.log` |
@@ -154,6 +155,15 @@
--- ---
## 4.4 客户自助查询 /customer
- **独立开关**:`/customer` 由 `portal_public` 控制(默认 `true`),和大屏 `screen_public` 互不影响;关掉大屏不会连带关掉客户页。
- **查法**:只接受 4–8 位数字手机尾号,精确匹配;同一尾号有多位客户时要求补姓名,并用「张* / Z***」这样的掩码提示,不直接列全名;每个 IP 每分钟最多 40 次查询。
- **展示**:今日待完成数量、订阅列表(通行证 / 深渊 / 按次 / 按日,含已到期、暂停、已用尽状态)、当月排单。月历里日常任务用空心/实心点表示未完成/已完成,通行证和深渊用连续色块并显示 `1/1` 或 `1/4 周期`;前后月只做日期占位。页面不含备注与价格。
- **隐私**:页面 `noindex`,HTML `no-store`;只显示排单与完成情况。若不需要客户自助,把 `portal_public` 改成 `false`。
---
## 4.3 通行证、深渊与按次有效期 ## 4.3 通行证、深渊与按次有效期
- **通行证**(原「包赛季」):套餐上直接填通行证开始日/结束日。客户开通后,该套餐的独立日历会把整段有效期连续着色(用套餐色签的淡化色),每一格显示同一个「已完成 / 总单数」(0/1 或 1/1)。有效期内任意一天点「完成通行证」即整段完成,完成后色块保留并显示 1/1。 - **通行证**(原「包赛季」):套餐上直接填通行证开始日/结束日。客户开通后,该套餐的独立日历会把整段有效期连续着色(用套餐色签的淡化色),每一格显示同一个「已完成 / 总单数」(0/1 或 1/1)。有效期内任意一天点「完成通行证」即整段完成,完成后色块保留并显示 1/1。
@@ -297,7 +307,7 @@ daili.ini 首次运行自动生成
单文件 SQLite + 每次操作即时落盘,正常使用不会丢;每天的自动备份是双保险。仍然建议定期把 `backup/` 拷到别的盘。 单文件 SQLite + 每次操作即时落盘,正常使用不会丢;每天的自动备份是双保险。仍然建议定期把 `backup/` 拷到别的盘。
**能放到公网吗?** **能放到公网吗?**
不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定,且大屏默认免登录。要放公网请套一层反向代理 + HTTPS + 访问控制,并把 `screen_public` 改成 `false`。 不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定,且大屏和客户自助页默认免登录。要放公网请套一层反向代理 + HTTPS + 访问控制,并把 `screen_public`、`portal_public` 改成 `false`。
--- ---
+12
View File
@@ -34,6 +34,7 @@ type Config struct {
Timezone string Timezone string
OpenBrowser bool OpenBrowser bool
ScreenPublic bool ScreenPublic bool
PortalPublic bool
LogEnabled bool LogEnabled bool
LogDir string LogDir string
@@ -57,6 +58,7 @@ func Defaults() *Config {
Timezone: "", Timezone: "",
OpenBrowser: true, OpenBrowser: true,
ScreenPublic: true, ScreenPublic: true,
PortalPublic: true,
LogEnabled: true, LogEnabled: true,
LogDir: "logs", LogDir: "logs",
} }
@@ -203,6 +205,12 @@ func (c *Config) set(key, val string) error {
return fmt.Errorf("screen_public %v", err) return fmt.Errorf("screen_public %v", err)
} }
c.ScreenPublic = b c.ScreenPublic = b
case "portal_public":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("portal_public %v", err)
}
c.PortalPublic = b
case "log_enabled": case "log_enabled":
b, err := parseBool(val) b, err := parseBool(val)
if err != nil { if err != nil {
@@ -307,6 +315,9 @@ open_browser = %t
# 大屏 /screen 是否免登录(默认 true:适合投屏;改成 false 则需要登录) # 大屏 /screen 是否免登录(默认 true:适合投屏;改成 false 则需要登录)
screen_public = %t screen_public = %t
# 客户自助查询 /customer 是否免登录(默认 true;不需要对外时可改成 false)
portal_public = %t
# ---------- 日志 ---------- # ---------- 日志 ----------
# 运行日志同时输出到控制台与 log_dir 下按天切分的文件 # 运行日志同时输出到控制台与 log_dir 下按天切分的文件
log_enabled = %t log_enabled = %t
@@ -322,6 +333,7 @@ log_dir = %s
c.Timezone, c.Timezone,
c.OpenBrowser, c.OpenBrowser,
c.ScreenPublic, c.ScreenPublic,
c.PortalPublic,
c.LogEnabled, c.LogEnabled,
c.LogDir, c.LogDir,
) )
+26 -1
View File
@@ -169,7 +169,6 @@ func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) {
// CheckinsBetween 取区间内打卡,索引为 [subID][date]。 // CheckinsBetween 取区间内打卡,索引为 [subID][date]。
func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, error) { 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), 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 IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
@@ -177,6 +176,32 @@ func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
return scanCheckinMap(rows)
}
// CheckinsForSubs 只取指定订阅在区间内的打卡,避免客户自助页把全部客户的打卡读进内存。
func (db *DB) CheckinsForSubs(ids []int64, from, to string) (map[int64]map[string]Checkin, error) {
out := map[int64]map[string]Checkin{}
if len(ids) == 0 {
return out, nil
}
args := make([]any, 0, len(ids)+2)
for _, id := range ids {
args = append(args, id)
}
args = append(args, from, to)
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 IN (`+placeholders(len(ids))+`) AND c.service_date BETWEEN ? AND ?`, args...)
if err != nil {
return nil, err
}
return scanCheckinMap(rows)
}
func scanCheckinMap(rows *sql.Rows) (map[int64]map[string]Checkin, error) {
out := map[int64]map[string]Checkin{}
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var c Checkin var c Checkin
+10 -1
View File
@@ -236,13 +236,22 @@ func (db *DB) CustomerCount() (int, error) {
return n, err return n, err
} }
// CustomersByTailExact 按手机尾号精确匹配客户(客户自助页用,不退回模糊匹配)。
func (db *DB) CustomersByTailExact(tail string) ([]Customer, error) {
tail = strings.TrimSpace(tail)
if tail == "" {
return nil, nil
}
return db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE IFNULL(archived,0)=0 AND tail = ? ORDER BY id`, tail)
}
// CustomersByTail 按手机尾号精确匹配客户;没有精确命中时退回模糊匹配。 // CustomersByTail 按手机尾号精确匹配客户;没有精确命中时退回模糊匹配。
func (db *DB) CustomersByTail(tail string) ([]Customer, error) { func (db *DB) CustomersByTail(tail string) ([]Customer, error) {
tail = strings.TrimSpace(tail) tail = strings.TrimSpace(tail)
if tail == "" { if tail == "" {
return nil, nil return nil, nil
} }
out, err := db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE IFNULL(archived,0)=0 AND tail = ? ORDER BY id`, tail) out, err := db.CustomersByTailExact(tail)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+2
View File
@@ -56,6 +56,7 @@ type settingsData struct {
BackupEnabled bool BackupEnabled bool
ConfigPath string ConfigPath string
ScreenPublic bool ScreenPublic bool
PortalPublic bool
Addrs []string Addrs []string
Port string Port string
Uptime string Uptime string
@@ -140,6 +141,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
BackupEnabled: s.cfg.BackupEnabled, BackupEnabled: s.cfg.BackupEnabled,
ConfigPath: s.cfg.ConfigPath, ConfigPath: s.cfg.ConfigPath,
ScreenPublic: s.cfg.ScreenPublic, ScreenPublic: s.cfg.ScreenPublic,
PortalPublic: s.cfg.PortalPublic,
Addrs: LocalAddrs(), Addrs: LocalAddrs(),
Port: port, Port: port,
Uptime: humanDuration(time.Since(s.started)), Uptime: humanDuration(time.Since(s.started)),
+262 -111
View File
@@ -2,15 +2,19 @@ package web
import ( import (
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url"
"regexp"
"strings" "strings"
"sync"
"time" "time"
"daili/internal/domain" "daili/internal/domain"
"daili/internal/store" "daili/internal/store"
) )
// portalDay 自助查询月历中的一天。 // portalDay 客户自助月历中的一天:日常任务按 done/total 计,通行证/深渊按整段色块展示。
type portalDay struct { type portalDay struct {
Date string Date string
Day int Day int
@@ -18,90 +22,137 @@ type portalDay struct {
Today bool Today bool
Total int Total int
Done int Done int
// 通行证 / 深渊色块
BlockColor string
BlockText string
BlockTitle string
BlockPercent int
// Label 是读屏用的整格描述
Label string
} }
// portalSub 自助查询里展示的订阅(隐藏备注、打手、价格)。 // portalSub 客户自助页里的一个订阅;复用管理端 SubView 的状态/剩余文案。
type portalSub struct { type portalSub struct {
Name string SubView
UnitLabel string TodayLabel string
Status string TodayClass string
Period string
TodayDone bool
} }
// portalAsk 同一尾号命中多个客户时要求补姓名。 // portalAsk 同一尾号命中多个客户时要求补姓名。
type portalAsk struct { type portalAsk struct {
Count int Tail string
Names []string Count int
Masked []string
} }
// portalView 查询结果。 // portalView 客户自助查询结果。
type portalView struct { type portalView struct {
Tail string Tail string
Name string Name string
Game string Game string
Subs []portalSub Subs []portalSub
Weeks [][]portalDay Weeks [][]portalDay
MonthTitle string MonthTitle string
Prev string Prev string
Next string Next string
TodayTotal int TodayTotal int
TodayDone int TodayDone int
AllDone bool TodayPending int
ActiveSubs int TodayState string // done / pending / none
Expiring string SubCount int
TodayPct int Expiring string
Count int Masked []string
Names []string }
var portalTailRe = regexp.MustCompile(`^\d{4,8}$`)
const (
portalRateLimit = 40
portalRateWindow = time.Minute
)
var portalHits = struct {
sync.Mutex
m map[string][]time.Time
}{m: map[string][]time.Time{}}
// allowPortal 对自助查询做简单的按 IP 限流,避免尾号枚举把库打穿。
func allowPortal(remote string) bool {
host, _, err := net.SplitHostPort(remote)
if err != nil || host == "" {
host = remote
}
now := time.Now()
portalHits.Lock()
defer portalHits.Unlock()
kept := portalHits.m[host][:0]
for _, t := range portalHits.m[host] {
if now.Sub(t) < portalRateWindow {
kept = append(kept, t)
}
}
if len(kept) >= portalRateLimit {
portalHits.m[host] = kept
return false
}
portalHits.m[host] = append(kept, now)
return true
} }
// handleCustomerPortal 客户自助查询页(公开,无入口链接)。 // handleCustomerPortal 客户自助查询页(公开,无入口链接)。
func (s *Server) handleCustomerPortal(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCustomerPortal(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
p := s.newPage(r, "排单查询", "") p := s.newPage(r, "排单查询", "")
q := r.URL.Query() q := r.URL.Query()
tail := strings.TrimSpace(q.Get("tail")) tail := strings.TrimSpace(q.Get("tail"))
name := strings.TrimSpace(q.Get("name")) name := strings.TrimSpace(q.Get("name"))
month := strings.TrimSpace(q.Get("cm")) month := strings.TrimSpace(q.Get("cm"))
p.Query = tail p.Query = tail
p.Name = name
if tail == "" { if tail == "" {
s.render(w, r, "portal", p) s.render(w, r, "portal", p)
return return
} }
if !allowPortal(r.RemoteAddr) {
list, err := s.db.CustomersByTail(tail) p.InlineError = "查询太频繁,请稍后再试"
if err != nil { s.render(w, r, "portal", p)
p.Error = "读取客户失败,请稍后再试" return
}
if !portalTailRe.MatchString(tail) {
p.InlineError = "请输入 4–8 位数字手机尾号"
p.InvalidField = "tail"
s.render(w, r, "portal", p)
return
}
list, err := s.db.CustomersByTailExact(tail)
if err != nil {
p.InlineError = "读取客户失败,请稍后再试"
s.render(w, r, "portal", p) s.render(w, r, "portal", p)
return return
} }
list = filterActive(list)
if len(list) == 0 { if len(list) == 0 {
p.Error = "没有找到这个尾号的客户,请核对尾号" p.InlineError = "没有找到这个尾号的客户,请核对尾号"
p.InvalidField = "tail"
s.render(w, r, "portal", p) s.render(w, r, "portal", p)
return return
} }
if len(list) > 1 { if len(list) > 1 {
if name == "" { if name == "" {
names := make([]string, 0, len(list)) p.D = &portalAsk{Tail: tail, Count: len(list), Masked: maskNames(list)}
for _, c := range list {
names = append(names, c.Name)
}
p.D = &portalAsk{Count: len(list), Names: names}
s.render(w, r, "portal", p) s.render(w, r, "portal", p)
return return
} }
matched := matchByName(list, name) matched := matchByName(list, name)
if len(matched) == 0 { if len(matched) == 0 {
p.Error = "尾号对得上,但姓名不匹配,请再确认一下" p.InlineError = "尾号对得上,但姓名不匹配,请再确认一下"
p.InvalidField = "name"
s.render(w, r, "portal", p) s.render(w, r, "portal", p)
return return
} }
if len(matched) > 1 { if len(matched) > 1 {
names := make([]string, 0, len(matched)) p.D = &portalAsk{Tail: tail, Count: len(matched), Masked: maskNames(matched)}
for _, c := range matched {
names = append(names, c.Name)
}
p.D = &portalAsk{Count: len(matched), Names: names}
s.render(w, r, "portal", p) s.render(w, r, "portal", p)
return return
} }
@@ -113,16 +164,6 @@ func (s *Server) handleCustomerPortal(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "portal", p) s.render(w, r, "portal", p)
} }
func filterActive(list []store.Customer) []store.Customer {
out := list[:0]
for _, c := range list {
if !c.Archived {
out = append(out, c)
}
}
return out
}
func matchByName(list []store.Customer, name string) []store.Customer { func matchByName(list []store.Customer, name string) []store.Customer {
needle := strings.ToLower(strings.TrimSpace(name)) needle := strings.ToLower(strings.TrimSpace(name))
out := make([]store.Customer, 0, len(list)) out := make([]store.Customer, 0, len(list))
@@ -134,10 +175,37 @@ func matchByName(list []store.Customer, name string) []store.Customer {
return out return out
} }
func maskNames(list []store.Customer) []string {
out := make([]string, 0, len(list))
seen := map[string]bool{}
for _, c := range list {
m := maskName(c.Name)
if seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
return out
}
// maskName 只保留首字,避免多客户态直接列出全名。
func maskName(name string) string {
rs := []rune(strings.TrimSpace(name))
switch len(rs) {
case 0:
return "*"
case 1:
return "*"
}
return string(rs[0]) + strings.Repeat("*", len(rs)-1)
}
// buildPortal 组装某位客户的排单视图。 // buildPortal 组装某位客户的排单视图。
func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portalView { func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portalView {
today := domain.Today() today := domain.Today()
todayStr := domain.Date(today) todayStr := domain.Date(today)
warn := s.warnDays()
first := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) first := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC)
if month != "" { if month != "" {
if t, err := time.Parse("2006-01", month); err == nil { if t, err := time.Parse("2006-01", month); err == nil {
@@ -150,58 +218,66 @@ func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portal
start := firstDay.AddDate(0, 0, -((int(firstDay.Weekday()) + 6) % 7)) start := firstDay.AddDate(0, 0, -((int(firstDay.Weekday()) + 6) % 7))
end := lastDay.AddDate(0, 0, 6-((int(lastDay.Weekday())+6)%7)) end := lastDay.AddDate(0, 0, 6-((int(lastDay.Weekday())+6)%7))
subs, _ := s.db.SubsByCustomer(c.ID) allSubs, _ := s.db.SubsByCustomer(c.ID)
active := make([]store.Subscription, 0, len(subs)) subs := make([]store.Subscription, 0, len(allSubs))
ids := make([]int64, 0, len(allSubs))
for _, sub := range allSubs {
if sub.Status == domain.StatusClosed {
continue
}
subs = append(subs, sub)
ids = append(ids, sub.ID)
}
cks, _ := s.db.CheckinsForSubs(ids, wideFrom(domain.Date(start)), wideTo(domain.Date(end)))
pauses, _ := s.db.PausesForSubs(ids)
cals := make([]calSub, 0, len(subs))
for _, sub := range subs { for _, sub := range subs {
if sub.Status != domain.StatusClosed { cs := calSub{
active = append(active, sub) sub: sub,
start: domain.ParseDateOr(sub.StartDate, today),
eval: sub.Eval(today, warn),
pauses: pauses[sub.ID],
checkins: cks[sub.ID],
} }
}
cov := func(sub store.Subscription, date string) bool {
if date < sub.StartDate {
return false
}
if sub.EndDate == "" {
return true
}
return date <= sub.EndDate
}
// 打卡记录
cks, _ := s.db.CheckinsBetween(domain.Date(start), domain.Date(end))
v := &portalView{Tail: c.Tail, Name: c.Name, Game: c.GameName, ActiveSubs: len(active)}
v.MonthTitle = fmt.Sprintf("%d 年 %d 月", year, mon)
q := "?tail=" + tail
if name != "" {
q += "&name=" + name
}
v.Prev = q + "&cm=" + firstDay.AddDate(0, -1, 0).Format("2006-01")
v.Next = q + "&cm=" + firstDay.AddDate(0, 1, 0).Format("2006-01")
for _, sub := range active {
label := "按日"
if sub.Season {
label = "包赛季"
} else if sub.Unit == domain.UnitCount {
label = "按次"
}
period := sub.StartDate
if sub.EndDate != "" { if sub.EndDate != "" {
period += " → " + sub.EndDate cs.hasEnd = true
} else { cs.end = domain.ParseDateOr(sub.EndDate, today)
period += " 起 · 无有效期"
} }
ps := portalSub{Name: sub.PackageName, UnitLabel: label, Status: sub.Status, Period: period} cals = append(cals, cs)
if m, ok := cks[sub.ID]; ok { }
if ck, ok2 := m[todayStr]; ok2 && ck.Status == "done" {
ps.TodayDone = true v := &portalView{
Tail: c.Tail,
Name: c.Name,
Game: c.GameName,
SubCount: len(subs),
}
v.MonthTitle = fmt.Sprintf("%d 年 %d 月", year, mon)
v.Prev = portalQuery(tail, name, firstDay.AddDate(0, -1, 0).Format("2006-01"))
v.Next = portalQuery(tail, name, firstDay.AddDate(0, 1, 0).Format("2006-01"))
for _, cs := range cals {
sub := cs.sub
ev := newSubView(sub, today, warn)
ps := portalSub{SubView: ev}
if ev.StateClass == "active" && !sub.Season && !sub.Abyss && cs.covers(todayStr) {
if ci, ok := cks[sub.ID][todayStr]; ok && portalDone(ci) {
ps.TodayLabel, ps.TodayClass = "今日已完成", "active"
} else {
ps.TodayLabel, ps.TodayClass = "今日未完成", "plain"
} }
} }
v.Subs = append(v.Subs, ps) v.Subs = append(v.Subs, ps)
if sub.EndDate != "" && (v.Expiring == "" || sub.EndDate < v.Expiring) {
v.Expiring = sub.EndDate if sub.EndDate != "" && sub.EndDate >= todayStr {
switch ev.Eval.State {
case domain.StateExpired, domain.StateUsedUp, domain.StateClosed:
default:
if v.Expiring == "" || sub.EndDate < v.Expiring {
v.Expiring = sub.EndDate
}
}
} }
} }
@@ -212,20 +288,33 @@ func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portal
d := start.AddDate(0, 0, i) d := start.AddDate(0, 0, i)
ds := domain.Date(d) ds := domain.Date(d)
pd := portalDay{Date: ds, Day: d.Day(), InMonth: d.Month() == time.Month(mon), Today: ds == todayStr} pd := portalDay{Date: ds, Day: d.Day(), InMonth: d.Month() == time.Month(mon), Today: ds == todayStr}
for _, sub := range active { // 与主日历一致:只在本月格子里显示任务,前后月只做日期占位。
if !cov(sub, ds) { if pd.InMonth {
continue for _, cs := range cals {
} sub := cs.sub
pd.Total++ if cs.isBlock() {
if m, ok := cks[sub.ID]; ok { if cs.blockCovers(ds) && pd.BlockText == "" {
if ck, ok2 := m[ds]; ok2 && ck.Status == "done" { pd.BlockColor = sub.PackageColor
pd.BlockText = fmt.Sprintf("%d/%d", cs.blockDone(), cs.blockTotal())
pd.BlockTitle = sub.PackageName + " · " + pd.BlockText
pd.BlockPercent = 18
if sub.Abyss && sub.CycleDays > 0 {
idx := domain.CycleIndex(cs.start, d, sub.CycleDays)
pd.BlockPercent = abyssBlockPercent(idx, sub.TotalCount)
}
}
continue
}
if !cs.covers(ds) {
continue
}
pd.Total++
if ci, ok := cks[sub.ID][ds]; ok && portalDone(ci) {
pd.Done++ pd.Done++
} }
} }
} }
if pd.Today { pd.Label = portalDayLabel(pd, mon)
v.TodayTotal, v.TodayDone = pd.Total, pd.Done
}
week = append(week, pd) week = append(week, pd)
if len(week) == 7 { if len(week) == 7 {
v.Weeks = append(v.Weeks, week) v.Weeks = append(v.Weeks, week)
@@ -235,9 +324,71 @@ func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portal
if len(week) > 0 { if len(week) > 0 {
v.Weeks = append(v.Weeks, week) v.Weeks = append(v.Weeks, week)
} }
v.AllDone = v.TodayTotal > 0 && v.TodayDone >= v.TodayTotal
if v.TodayTotal > 0 { // 今日进度:整段型订阅算 1 项,通行证看 season_done,深渊看当天所在周期。
v.TodayPct = v.TodayDone * 100 / v.TodayTotal for _, cs := range cals {
if cs.isBlock() {
if cs.blockCovers(todayStr) {
v.TodayTotal++
if !cs.blockNeedsWork(todayStr) {
v.TodayDone++
}
}
continue
}
if !cs.covers(todayStr) {
continue
}
v.TodayTotal++
if ci, ok := cks[cs.sub.ID][todayStr]; ok && portalDone(ci) {
v.TodayDone++
}
}
v.TodayPending = v.TodayTotal - v.TodayDone
switch {
case v.TodayTotal == 0:
v.TodayState = "none"
case v.TodayPending <= 0:
v.TodayState = "done"
default:
v.TodayState = "pending"
} }
return v return v
} }
// portalDone 与管理端日历口径一致:done 和 skipped 都算当天已处理。
func portalDone(ci store.Checkin) bool {
return ci.Status == "done" || ci.Status == "skipped"
}
func portalDayLabel(pd portalDay, month int) string {
labelMonth := month
if t, err := time.Parse("2006-01-02", pd.Date); err == nil {
labelMonth = int(t.Month())
}
label := fmt.Sprintf("%d 月 %d 日", labelMonth, pd.Day)
if pd.Today {
label += ",今天"
}
if pd.BlockText != "" {
label += "," + pd.BlockTitle
}
if pd.Total > 0 {
label += fmt.Sprintf(",共 %d 单,已完成 %d 单", pd.Total, pd.Done)
}
if pd.BlockText == "" && pd.Total == 0 {
label += ",无排单"
}
return label
}
// portalQuery 用 url.Values 拼翻月链接,姓名含 & / 空格时也不会丢参数。
func portalQuery(tail, name, month string) string {
q := url.Values{}
q.Set("tail", tail)
if name != "" {
q.Set("name", name)
}
q.Set("cm", month)
return "/customer?" + q.Encode()
}
+8 -1
View File
@@ -45,6 +45,7 @@ type Config struct {
BackupDir string BackupDir string
BackupEnabled bool BackupEnabled bool
ScreenPublic bool ScreenPublic bool
PortalPublic bool
ConfigPath string ConfigPath string
Version string Version string
} }
@@ -154,9 +155,12 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive)) mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive))
mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail)) mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail))
mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe)) mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe))
if s.cfg.PortalPublic {
// 客户自助查询:独立开关,不受大屏 screen_public 影响
mux.HandleFunc("GET /customer", s.handleCustomerPortal)
}
if s.cfg.ScreenPublic { if s.cfg.ScreenPublic {
// 大屏免登录:仅局域网部署时使用,内容为只读看板 // 大屏免登录:仅局域网部署时使用,内容为只读看板
mux.HandleFunc("GET /customer", s.handleCustomerPortal)
mux.HandleFunc("GET /screen", s.handleScreen) mux.HandleFunc("GET /screen", s.handleScreen)
mux.HandleFunc("GET /api/screen/day", s.handleScreenDay) mux.HandleFunc("GET /api/screen/day", s.handleScreenDay)
} else { } else {
@@ -372,6 +376,9 @@ type page struct {
// AssetVersion 是内嵌静态资源的内容指纹,用于 /static/*.css?v=... 的长缓存 // AssetVersion 是内嵌静态资源的内容指纹,用于 /static/*.css?v=... 的长缓存
AssetVersion string AssetVersion string
Query string Query string
Name string
InlineError string
InvalidField string
Next string Next string
DueToday int DueToday int
Due3 int Due3 int
+114 -15
View File
@@ -1259,24 +1259,123 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
/* ---------- 客户自助查询 /customer ---------- */ /* ---------- 客户自助查询 /customer ---------- */
.portal-wrap { max-width: 980px; margin: 0 auto; padding: 24px 16px 48px; } .portal-wrap { max-width: 980px; margin: 0 auto; padding: 24px 16px 56px; }
.portal-head h1 { color: var(--app-text) !important; -webkit-text-fill-color: currentColor; background: none !important; font-size: 1.6rem; font-weight: 800; margin: 0 0 4px; background: linear-gradient(135deg, var(--app-accent-strong), var(--app-accent)); -webkit-background-clip: text; background-clip: text; color: transparent; } .portal-head { margin-bottom: 18px; }
.portal-form { border-radius: 1rem; } .portal-brand {
.portal-progress .bar { height: 8px; border-radius: 999px; background: var(--app-surface-3); overflow: hidden; margin-bottom: 6px; } display: inline-flex; align-items: center; gap: 8px;
.portal-progress .bar i { display: block; height: 100%; background: linear-gradient(135deg, var(--app-accent-strong), var(--app-accent)); border-radius: 999px; } margin-bottom: 12px; color: var(--app-text); text-decoration: none;
.pcal { border: 1px solid var(--app-border); border-radius: .85rem; overflow: hidden; } font-weight: 700; font-size: .9375rem;
.pcal-dow { display: grid; grid-template-columns: repeat(7, 1fr); background: var(--app-surface-3); } }
.pcal-dow span { text-align: center; padding: 6px 0; font-size: .72rem; color: var(--app-text-3); } .portal-brand:hover { color: var(--app-text); }
.portal-brand .brand-mark { width: 26px; height: 26px; font-size: .8125rem; }
.portal-head h1 { font-size: 1.6rem; font-weight: 800; margin: 0 0 4px; color: var(--app-text); }
.portal-head p { margin: 0; }
.portal-error { margin: 0 0 14px; }
.portal-search {
border: 1px solid var(--app-border); border-radius: var(--app-radius);
background: var(--app-surface); box-shadow: var(--app-shadow-sm);
overflow: hidden;
}
.portal-search-head {
display: flex; align-items: center; gap: 8px;
padding: 13px 16px; cursor: pointer; list-style: none;
font-weight: 600; color: var(--app-text);
}
.portal-search-head::-webkit-details-marker { display: none; }
.portal-search-head::after { content: "▾"; margin-left: auto; color: var(--app-text-3); font-size: .75rem; }
.portal-search[open] .portal-search-head { border-bottom: 1px solid var(--app-border); }
.portal-search[open] .portal-search-head::after { transform: rotate(180deg); }
.portal-form-body { padding: 14px 16px 16px; }
.portal-form-body .form-label { font-size: .8125rem; color: var(--app-text-2); margin-bottom: 5px; }
.portal-client { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; }
.portal-client-main { min-width: 0; }
.portal-client-name { font-size: 1.5rem; font-weight: 800; margin: 0 0 6px; }
.portal-client-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.portal-client-status { margin-left: auto; }
.portal-client-status .chip { font-size: .8125rem; }
.portal-section-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.portal-section-head h2 { font-size: 1rem; font-weight: 700; margin: 0; }
.portal-section-count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px;
background: var(--app-surface-3); color: var(--app-text-2);
font-size: .75rem; font-weight: 600;
}
.portal-sub-list { margin-top: 2px; }
.portal-sub { padding: 12px 0; border-top: 1px solid var(--app-border); }
.portal-sub:first-child { border-top: 0; }
.portal-sub-main { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.portal-sub-main strong { font-size: .9375rem; }
.portal-sub-meta {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px;
margin-top: 4px; font-size: .8125rem; color: var(--app-text-2);
}
.portal-sub-meta .muted { color: var(--app-text-3); }
.portal-ask-names { font-size: .875rem; color: var(--app-text-2); }
.portal-ask-form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.portal-ask-form .form-control { flex: 1 1 200px; width: auto; }
.pcal-nav { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.pcal-nav b { font-size: .875rem; min-width: 92px; text-align: center; }
.pcal-nav-btn {
width: 44px; height: 44px; padding: 0;
display: inline-flex; align-items: center; justify-content: center;
font-size: 1.125rem; line-height: 1;
}
.pcal { margin-top: 10px; max-width: 880px; margin-left: auto; margin-right: auto; }
.pcal-dow { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); background: var(--app-surface-2); border-bottom: 1px solid var(--app-border); }
.pcal-dow span { padding: 6px 0; text-align: center; font-size: .72rem; color: var(--app-text-3); }
.pcal-row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } .pcal-row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.pcal-cell { min-height: 54px; padding: 6px 8px; border-top: 1px solid var(--app-border); border-right: 1px solid var(--app-border); display: flex; flex-direction: column; gap: 2px; } .pcal-cell {
position: relative;
display: flex; flex-direction: column; gap: 2px;
min-width: 0; min-height: 56px; padding: 6px 8px;
border-top: 1px solid var(--app-border); border-right: 1px solid var(--app-border);
background: var(--app-surface);
}
.pcal-cell:nth-child(7) { border-right: 0; } .pcal-cell:nth-child(7) { border-right: 0; }
.pcal-cell b { font-size: .82rem; color: var(--app-text); } .pcal-cell b { font-size: .8125rem; color: var(--app-text); }
.pcal-cell span { font-size: .72rem; color: var(--app-accent); font-weight: 700; } .pcal-cell.out { background: var(--app-surface-2); }
.pcal-cell.out { opacity: .4; } .pcal-cell.out b, .pcal-cell.out span, .pcal-cell.out .pcal-count { color: var(--app-text-3); }
.pcal-cell.today { background: var(--app-accent-soft); box-shadow: inset 0 0 0 2px var(--app-accent); } .pcal-cell.today { background: var(--app-accent-soft); box-shadow: inset 0 0 0 1.5px var(--app-accent); }
.pcal-today {
position: absolute; top: 5px; right: 6px;
font-style: normal; font-size: .5625rem; font-weight: 700;
color: var(--app-accent-ink); letter-spacing: 0;
}
.pcal-cell.has-block {
background: color-mix(in srgb, var(--block) var(--block-pct, 18%), var(--app-surface));
border-color: color-mix(in srgb, var(--block) 22%, var(--app-border));
}
.pcal-block {
display: inline-flex; align-items: center; gap: 4px; align-self: flex-start;
padding: 1px 6px; border-radius: 999px;
font-size: .6875rem; font-weight: 700; white-space: nowrap;
color: var(--app-text);
background: color-mix(in srgb, var(--block) 16%, var(--app-surface));
border: 1px solid color-mix(in srgb, var(--block) 28%, transparent);
}
.pcal-block::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--block); }
.pcal-dot { width: 7px; height: 7px; margin-top: auto; border-radius: 50%; border: 1.5px solid var(--app-accent); }
.pcal-dot.done { background: var(--app-accent); border-color: var(--app-accent); }
.pcal-count { margin-top: auto; font-size: .75rem; font-weight: 700; color: var(--app-accent); }
@media (max-width: 575px) { @media (max-width: 575px) {
.portal-wrap { padding: 16px 10px 32px; } .portal-wrap { padding: 16px 10px 40px; }
.pcal-cell { min-height: 46px; padding: 4px 5px; } .portal-head h1 { font-size: 1.45rem; }
.portal-client-name { font-size: 1.3rem; }
.portal-client-status { margin-left: 0; }
.pcal-cell { min-height: 50px; padding: 4px 5px; }
.pcal-today { top: 3px; right: 4px; font-size: .5rem; }
.pcal-block { font-size: .625rem; padding: 1px 4px; }
.pcal-nav b { min-width: 74px; }
} }
.tagchip, .pkg-tag { .tagchip, .pkg-tag {
+90 -65
View File
@@ -1,98 +1,123 @@
{{define "content"}} {{define "content"}}
<div class="portal-wrap"> <div class="portal-wrap">
<header class="portal-head"> <header class="portal-head">
<div> <a class="portal-brand" href="/customer"><span class="brand-mark" aria-hidden="true">代</span><span>代打台</span></a>
<h1>排单查询</h1> <h1>排单查询</h1>
<p class="muted">输入手机尾号查看自己的排单与完成情况{{if not .D}};同一尾号有多位客户时需要再填姓名{{end}}</p> <p class="muted">输入手机尾号查看自己的排单与完成情况。</p>
</div>
</header> </header>
<form class="portal-form card" method="get" action="/customer"> {{if .InlineError}}<div class="notice notice-danger portal-error" id="portal-error" role="alert">{{.InlineError}}</div>{{end}}
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-12 col-sm-4">
<label class="form-label" for="tail">手机尾号</label>
<input class="form-control" id="tail" name="tail" value="{{.Query}}" inputmode="numeric" maxlength="8" placeholder="例如 2300" required autofocus>
</div>
<div class="col-12 col-sm-4">
<label class="form-label" for="name">客户姓名(可选)</label>
<input class="form-control" id="name" name="name" maxlength="32" placeholder="尾号重复时填写">
</div>
<div class="col-12 col-sm-4">
<button class="btn btn-primary w-100" type="submit">查询</button>
</div>
</div>
<p class="muted small mb-0 mt-2">只显示排单与完成情况,不含备注与价格。如有疑问请联系代打。</p>
</div>
</form>
{{if .Error}}<div class="alert alert-warning mt-3" role="alert">{{.Error}}</div>{{end}} <details class="portal-search" {{if or (not .Query) .InlineError}}open{{end}}>
<summary class="portal-search-head">{{if .InlineError}}修改查询{{else if .Query}}查询其他尾号{{else}}输入手机尾号查询{{end}}</summary>
<form class="portal-form" method="get" action="/customer">
<div class="portal-form-body">
<div class="row g-2 align-items-end">
<div class="col-12 col-sm-4">
<label class="form-label" for="tail">手机尾号(4–8 位)</label>
<input class="form-control" id="tail" name="tail" value="{{.Query}}" inputmode="numeric" pattern="[0-9]{4,8}" minlength="4" maxlength="8" placeholder="例如 2300" required{{if not .Query}} autofocus{{end}}{{if eq .InvalidField "tail"}} aria-invalid="true" aria-describedby="portal-error"{{end}}>
</div>
<div class="col-12 col-sm-4">
<label class="form-label" for="name">客户姓名(可选)</label>
<input class="form-control" id="name" name="name" value="{{.Name}}" maxlength="32" placeholder="尾号重复时填写"{{if eq .InvalidField "name"}} aria-invalid="true" aria-describedby="portal-error"{{end}}>
</div>
<div class="col-12 col-sm-4">
<button class="btn btn-primary w-100" type="submit">查询</button>
</div>
</div>
<p class="muted small mb-0 mt-2">只显示排单与完成情况,不含备注与价格。</p>
</div>
</form>
</details>
{{with .D}} {{with .D}}
{{if .Names}} {{if .Masked}}
<div class="card mt-3"><div class="card-body">
<h2 class="h5">找到 {{.Count}} 位客户</h2>
<p class="muted mb-2">同一尾号有多位客户,请在上方补填姓名(支持模糊匹配)后重新查询。</p>
</div></div>
{{else}}
<section class="portal-result"> <section class="portal-result">
<div class="card mt-3"><div class="card-body"> <div class="card mt-3"><div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-2"> <h2 class="h5">找到 {{.Count}} 位客户</h2>
<h2 class="h4 mb-0">{{.Tail}} · {{.Name}}</h2> <p class="muted mb-2">尾号 {{.Tail}} 对应多位客户,请补一个姓名后查询。</p>
{{if .Game}}<span class="tagchip">{{.Game}}</span>{{end}} {{if .Masked}}<p class="portal-ask-names">可能是:{{range $i, $n := .Masked}}{{if $i}}、{{end}}{{$n}}{{end}}</p>{{end}}
<span class="tagchip">{{.ActiveSubs}} 个订阅</span> <form class="portal-ask-form" method="get" action="/customer">
{{if .Expiring}}<span class="tagchip">最近到期 {{.Expiring}}</span>{{end}} <input type="hidden" name="tail" value="{{.Tail}}">
<span class="ms-auto"> <label class="visually-hidden" for="ask-name">姓名</label>
{{if .AllDone}}<span class="badge text-bg-success">今日已清完</span> <input class="form-control" id="ask-name" name="name" maxlength="32" placeholder="输入姓名(可只填一部分)" required autofocus>
{{else}}<span class="badge text-bg-warning">今日待完成 {{.TodayDone}} / {{.TodayTotal}}</span>{{end}} <button class="btn btn-primary" type="submit">查询</button>
</span> </form>
</div> </div></div>
<div class="portal-progress mt-3"> </section>
<div class="bar"><i style="width:{{.TodayPct}}%"></i></div> {{else}}
<span class="muted small">今日 {{.TodayDone}} / {{.TodayTotal}} 单完成</span> <section class="portal-result">
{{if .Subs}}
<div class="card mt-3"><div class="card-body">
<div class="portal-client">
<div class="portal-client-main">
<h2 class="portal-client-name">{{.Tail}} · {{.Name}}</h2>
<div class="portal-client-meta">
{{if .Game}}<span class="tagchip">{{.Game}}</span>{{end}}
{{if .Expiring}}<span class="tagchip">最近到期 {{.Expiring}}</span>{{end}}
</div>
</div>
<div class="portal-client-status">
{{if eq .TodayState "done"}}<span class="chip chip-active">今日已清完</span>
{{else if eq .TodayState "pending"}}<span class="chip chip-plain">今日待完成 {{.TodayPending}} / {{.TodayTotal}}</span>
{{else}}<span class="chip chip-plain">今日无排单</span>{{end}}
</div>
</div> </div>
</div></div> </div></div>
{{if .Subs}} <div class="card mt-3"><div class="card-body">
<div class="row g-3 mt-1"> <div class="portal-section-head"><h2>订阅</h2><span class="portal-section-count">{{.SubCount}}</span></div>
{{range .Subs}} <div class="portal-sub-list">
<div class="col-12 col-md-6 col-xl-4"> {{range .Subs}}
<div class="card h-100"><div class="card-body"> <article class="portal-sub">
<div class="d-flex align-items-center gap-2 mb-1"> <div class="portal-sub-main">
<strong>{{.Name}}</strong> <strong>{{.PackageName}}</strong>
<span class="tagchip">{{.UnitLabel}}</span> <span class="tagchip">{{.UnitLabel}}</span>
<span class="ms-auto">{{if .TodayDone}}<span class="badge text-bg-success">今日已完成</span>{{else}}<span class="badge text-bg-secondary">今日未完成</span>{{end}}</span> {{if .TodayLabel}}<span class="chip chip-{{.TodayClass}}">{{.TodayLabel}}</span>{{else}}{{template "state-chip" .}}{{end}}
</div> </div>
<div class="muted small">{{.Period}}</div> <div class="portal-sub-meta">
</div></div> <span>{{.RemainText}}</span>
<span class="muted">{{.PeriodText}}</span>
</div>
</article>
{{end}}
</div> </div>
{{end}} </div></div>
</div>
{{end}}
<div class="card mt-3"><div class="card-body"> <div class="card mt-3"><div class="card-body">
<div class="d-flex align-items-center mb-2"> <div class="portal-section-head">
<h3 class="h6 mb-0">当月排单</h3> <h2>当月排单</h2>
<div class="ms-auto d-flex align-items-center gap-2"> <div class="pcal-nav">
<a class="btn btn-sm btn-outline-secondary" href="{{.Prev}}">‹</a> <a class="btn btn-soft pcal-nav-btn" href="{{.Prev}}" aria-label="上个月">‹</a>
<b>{{.MonthTitle}}</b> <b>{{.MonthTitle}}</b>
<a class="btn btn-sm btn-outline-secondary" href="{{.Next}}">›</a> <a class="btn btn-soft pcal-nav-btn" href="{{.Next}}" aria-label="下个月">›</a>
</div> </div>
</div> </div>
<div class="pcal"> <div class="pcal" role="grid" aria-label="{{.MonthTitle}}排单">
<div class="pcal-dow"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div> <div class="pcal-dow" role="row">
<span role="columnheader">一</span><span role="columnheader">二</span><span role="columnheader">三</span><span role="columnheader">四</span><span role="columnheader">五</span><span role="columnheader">六</span><span role="columnheader">日</span>
</div>
{{range .Weeks}} {{range .Weeks}}
<div class="pcal-row"> <div class="pcal-row" role="row">
{{range .}} {{range .}}
<div class="pcal-cell{{if not .InMonth}} out{{end}}{{if .Today}} today{{end}}"> <div class="pcal-cell{{if not .InMonth}} out{{end}}{{if .Today}} today{{end}}{{if .BlockColor}} has-block{{end}}" role="gridcell" aria-label="{{.Label}}"{{if .BlockColor}} style="--block:{{.BlockColor}};--block-pct:{{.BlockPercent}}%"{{end}}>
<b>{{.Day}}</b> <b>{{.Day}}</b>
{{if and .InMonth .Total}}<span>{{.Done}}/{{.Total}}</span>{{end}} {{if .Today}}<i class="pcal-today">今天</i>{{end}}
{{if .BlockText}}<span class="pcal-block" title="{{.BlockTitle}}">{{.BlockText}}</span>{{end}}
{{if .Total}}{{if eq .Total 1}}<span class="pcal-dot{{if .Done}} done{{end}}" title="{{.Done}}/{{.Total}} 单"></span>{{else}}<span class="pcal-count">{{.Done}}/{{.Total}}</span>{{end}}{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
</div> </div>
</div></div> </div></div>
{{else}}
<div class="card mt-3"><div class="card-body">
<h2 class="h4 mb-1">{{.Tail}} · {{.Name}}</h2>
<div class="portal-client-meta">{{if .Game}}<span class="tagchip">{{.Game}}</span>{{end}}</div>
<p class="muted mb-0 mt-3">还没有进行中的订阅。如需开通,请联系你的代打。</p>
</div></div>
{{end}}
</section> </section>
{{end}} {{end}}
{{end}} {{end}}
@@ -89,6 +89,7 @@
<div><dt>数据库</dt><dd class="mono small">{{.D.DBPath}}</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">{{.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">/screen {{if .D.ScreenPublic}}免登录{{else}}需登录{{end}}</dd></div>
<div><dt>客户自助</dt><dd class="mono small">/customer {{if .D.PortalPublic}}免登录{{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 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 class="mono">{{.D.Uptime}}</dd></div>
<div><dt>数据量</dt><dd>{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志</dd></div> <div><dt>数据量</dt><dd>{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志</dd></div>
+2 -1
View File
@@ -26,7 +26,7 @@ import (
"daili/internal/web" "daili/internal/web"
) )
const version = "1.11.2" const version = "1.11.3"
func main() { func main() {
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")") cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
@@ -131,6 +131,7 @@ func main() {
BackupDir: cfg.BackupDir, BackupDir: cfg.BackupDir,
BackupEnabled: cfg.BackupEnabled, BackupEnabled: cfg.BackupEnabled,
ScreenPublic: cfg.ScreenPublic, ScreenPublic: cfg.ScreenPublic,
PortalPublic: cfg.PortalPublic,
ConfigPath: cfg.Path, ConfigPath: cfg.Path,
Version: version, Version: version,
}) })