diff --git a/README.md b/README.md index 5efc34b..09cc865 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ ┌───────────────────────────────────────────────┐ │ 代 打 台 · 代打管理平台 │ └───────────────────────────────────────────────┘ - 版本 1.11.2 配置文件 daili.ini + 版本 1.11.3 配置文件 daili.ini 数据库 D:\代打台\daili.db 访问地址: 本机 http://127.0.0.1:17777 @@ -49,6 +49,7 @@ | `timezone` | 空 | 留空跟随系统;可固定 `Asia/Shanghai`。**影响所有“自然日”计算** | | `open_browser` | `true` | 启动后自动打开浏览器 | | `screen_public` | `true` | 大屏 `/screen` 免登录(投屏用);改 `false` 则需登录 | +| `portal_public` | `true` | 客户自助查询 `/customer` 免登录;改 `false` 则关闭该页(不影响大屏) | | `log_enabled` | `true` | 运行日志写文件(控制台始终输出) | | `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 通行证、深渊与按次有效期 - **通行证**(原「包赛季」):套餐上直接填通行证开始日/结束日。客户开通后,该套餐的独立日历会把整段有效期连续着色(用套餐色签的淡化色),每一格显示同一个「已完成 / 总单数」(0/1 或 1/1)。有效期内任意一天点「完成通行证」即整段完成,完成后色块保留并显示 1/1。 @@ -297,7 +307,7 @@ daili.ini 首次运行自动生成 单文件 SQLite + 每次操作即时落盘,正常使用不会丢;每天的自动备份是双保险。仍然建议定期把 `backup/` 拷到别的盘。 **能放到公网吗?** -不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定,且大屏默认免登录。要放公网请套一层反向代理 + HTTPS + 访问控制,并把 `screen_public` 改成 `false`。 +不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定,且大屏和客户自助页默认免登录。要放公网请套一层反向代理 + HTTPS + 访问控制,并把 `screen_public`、`portal_public` 改成 `false`。 --- diff --git a/internal/config/config.go b/internal/config/config.go index 3ca65a1..c782c81 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,6 +34,7 @@ type Config struct { Timezone string OpenBrowser bool ScreenPublic bool + PortalPublic bool LogEnabled bool LogDir string @@ -57,6 +58,7 @@ func Defaults() *Config { Timezone: "", OpenBrowser: true, ScreenPublic: true, + PortalPublic: true, LogEnabled: true, LogDir: "logs", } @@ -203,6 +205,12 @@ func (c *Config) set(key, val string) error { return fmt.Errorf("screen_public %v", err) } 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": b, err := parseBool(val) if err != nil { @@ -307,6 +315,9 @@ open_browser = %t # 大屏 /screen 是否免登录(默认 true:适合投屏;改成 false 则需要登录) screen_public = %t +# 客户自助查询 /customer 是否免登录(默认 true;不需要对外时可改成 false) +portal_public = %t + # ---------- 日志 ---------- # 运行日志同时输出到控制台与 log_dir 下按天切分的文件 log_enabled = %t @@ -322,6 +333,7 @@ log_dir = %s c.Timezone, c.OpenBrowser, c.ScreenPublic, + c.PortalPublic, c.LogEnabled, c.LogDir, ) diff --git a/internal/store/checkins.go b/internal/store/checkins.go index bc40643..44f8774 100644 --- a/internal/store/checkins.go +++ b/internal/store/checkins.go @@ -169,7 +169,6 @@ func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) { // 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 @@ -177,6 +176,32 @@ func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, er if err != nil { 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() for rows.Next() { var c Checkin diff --git a/internal/store/customers.go b/internal/store/customers.go index d3fd64e..5f6c403 100644 --- a/internal/store/customers.go +++ b/internal/store/customers.go @@ -236,13 +236,22 @@ func (db *DB) CustomerCount() (int, error) { 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 按手机尾号精确匹配客户;没有精确命中时退回模糊匹配。 func (db *DB) CustomersByTail(tail string) ([]Customer, error) { tail = strings.TrimSpace(tail) if tail == "" { 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 { return nil, err } diff --git a/internal/web/handlers_misc.go b/internal/web/handlers_misc.go index d5cfc2d..76eb324 100644 --- a/internal/web/handlers_misc.go +++ b/internal/web/handlers_misc.go @@ -56,6 +56,7 @@ type settingsData struct { BackupEnabled bool ConfigPath string ScreenPublic bool + PortalPublic bool Addrs []string Port string Uptime string @@ -140,6 +141,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { BackupEnabled: s.cfg.BackupEnabled, ConfigPath: s.cfg.ConfigPath, ScreenPublic: s.cfg.ScreenPublic, + PortalPublic: s.cfg.PortalPublic, Addrs: LocalAddrs(), Port: port, Uptime: humanDuration(time.Since(s.started)), diff --git a/internal/web/handlers_portal.go b/internal/web/handlers_portal.go index 4044317..bcd321b 100644 --- a/internal/web/handlers_portal.go +++ b/internal/web/handlers_portal.go @@ -2,15 +2,19 @@ package web import ( "fmt" + "net" "net/http" + "net/url" + "regexp" "strings" + "sync" "time" "daili/internal/domain" "daili/internal/store" ) -// portalDay 自助查询月历中的一天。 +// portalDay 客户自助月历中的一天:日常任务按 done/total 计,通行证/深渊按整段色块展示。 type portalDay struct { Date string Day int @@ -18,90 +22,137 @@ type portalDay struct { Today bool Total int Done int + // 通行证 / 深渊色块 + BlockColor string + BlockText string + BlockTitle string + BlockPercent int + // Label 是读屏用的整格描述 + Label string } -// portalSub 自助查询里展示的订阅(隐藏备注、打手、价格)。 +// portalSub 客户自助页里的一个订阅;复用管理端 SubView 的状态/剩余文案。 type portalSub struct { - Name string - UnitLabel string - Status string - Period string - TodayDone bool + SubView + TodayLabel string + TodayClass string } // portalAsk 同一尾号命中多个客户时要求补姓名。 type portalAsk struct { - Count int - Names []string + Tail string + Count int + Masked []string } -// portalView 查询结果。 +// portalView 客户自助查询结果。 type portalView struct { - Tail string - Name string - Game string - Subs []portalSub - Weeks [][]portalDay - MonthTitle string - Prev string - Next string - TodayTotal int - TodayDone int - AllDone bool - ActiveSubs int - Expiring string - TodayPct int - Count int - Names []string + Tail string + Name string + Game string + Subs []portalSub + Weeks [][]portalDay + MonthTitle string + Prev string + Next string + TodayTotal int + TodayDone int + TodayPending int + TodayState string // done / pending / none + SubCount int + Expiring string + Masked []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 客户自助查询页(公开,无入口链接)。 func (s *Server) handleCustomerPortal(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Robots-Tag", "noindex, nofollow") p := s.newPage(r, "排单查询", "") q := r.URL.Query() tail := strings.TrimSpace(q.Get("tail")) name := strings.TrimSpace(q.Get("name")) month := strings.TrimSpace(q.Get("cm")) p.Query = tail + p.Name = name + if tail == "" { s.render(w, r, "portal", p) return } - - list, err := s.db.CustomersByTail(tail) - if err != nil { - p.Error = "读取客户失败,请稍后再试" + if !allowPortal(r.RemoteAddr) { + p.InlineError = "查询太频繁,请稍后再试" + s.render(w, r, "portal", p) + 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) return } - list = filterActive(list) if len(list) == 0 { - p.Error = "没有找到这个尾号的客户,请核对尾号" + p.InlineError = "没有找到这个尾号的客户,请核对尾号" + p.InvalidField = "tail" s.render(w, r, "portal", p) return } if len(list) > 1 { if name == "" { - names := make([]string, 0, len(list)) - for _, c := range list { - names = append(names, c.Name) - } - p.D = &portalAsk{Count: len(list), Names: names} + p.D = &portalAsk{Tail: tail, Count: len(list), Masked: maskNames(list)} s.render(w, r, "portal", p) return } matched := matchByName(list, name) if len(matched) == 0 { - p.Error = "尾号对得上,但姓名不匹配,请再确认一下" + p.InlineError = "尾号对得上,但姓名不匹配,请再确认一下" + p.InvalidField = "name" s.render(w, r, "portal", p) return } if len(matched) > 1 { - names := make([]string, 0, len(matched)) - for _, c := range matched { - names = append(names, c.Name) - } - p.D = &portalAsk{Count: len(matched), Names: names} + p.D = &portalAsk{Tail: tail, Count: len(matched), Masked: maskNames(matched)} s.render(w, r, "portal", p) return } @@ -113,16 +164,6 @@ func (s *Server) handleCustomerPortal(w http.ResponseWriter, r *http.Request) { 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 { needle := strings.ToLower(strings.TrimSpace(name)) out := make([]store.Customer, 0, len(list)) @@ -134,10 +175,37 @@ func matchByName(list []store.Customer, name string) []store.Customer { 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 组装某位客户的排单视图。 func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portalView { today := domain.Today() todayStr := domain.Date(today) + warn := s.warnDays() first := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) if month != "" { 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)) end := lastDay.AddDate(0, 0, 6-((int(lastDay.Weekday())+6)%7)) - subs, _ := s.db.SubsByCustomer(c.ID) - active := make([]store.Subscription, 0, len(subs)) + allSubs, _ := s.db.SubsByCustomer(c.ID) + 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 { - if sub.Status != domain.StatusClosed { - active = append(active, sub) + cs := calSub{ + 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 != "" { - period += " → " + sub.EndDate - } else { - period += " 起 · 无有效期" + cs.hasEnd = true + cs.end = domain.ParseDateOr(sub.EndDate, today) } - ps := portalSub{Name: sub.PackageName, UnitLabel: label, Status: sub.Status, Period: period} - if m, ok := cks[sub.ID]; ok { - if ck, ok2 := m[todayStr]; ok2 && ck.Status == "done" { - ps.TodayDone = true + cals = append(cals, cs) + } + + 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) - 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) ds := domain.Date(d) pd := portalDay{Date: ds, Day: d.Day(), InMonth: d.Month() == time.Month(mon), Today: ds == todayStr} - for _, sub := range active { - if !cov(sub, ds) { - continue - } - pd.Total++ - if m, ok := cks[sub.ID]; ok { - if ck, ok2 := m[ds]; ok2 && ck.Status == "done" { + // 与主日历一致:只在本月格子里显示任务,前后月只做日期占位。 + if pd.InMonth { + for _, cs := range cals { + sub := cs.sub + if cs.isBlock() { + if cs.blockCovers(ds) && pd.BlockText == "" { + 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++ } } } - if pd.Today { - v.TodayTotal, v.TodayDone = pd.Total, pd.Done - } + pd.Label = portalDayLabel(pd, mon) week = append(week, pd) if len(week) == 7 { 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 { v.Weeks = append(v.Weeks, week) } - v.AllDone = v.TodayTotal > 0 && v.TodayDone >= v.TodayTotal - if v.TodayTotal > 0 { - v.TodayPct = v.TodayDone * 100 / v.TodayTotal + + // 今日进度:整段型订阅算 1 项,通行证看 season_done,深渊看当天所在周期。 + 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 } + +// 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() +} diff --git a/internal/web/server.go b/internal/web/server.go index 966c93c..014f872 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -45,6 +45,7 @@ type Config struct { BackupDir string BackupEnabled bool ScreenPublic bool + PortalPublic bool ConfigPath 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("GET /packages/{id}", s.auth(s.handlePackageDetail)) 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 { // 大屏免登录:仅局域网部署时使用,内容为只读看板 - mux.HandleFunc("GET /customer", s.handleCustomerPortal) mux.HandleFunc("GET /screen", s.handleScreen) mux.HandleFunc("GET /api/screen/day", s.handleScreenDay) } else { @@ -372,6 +376,9 @@ type page struct { // AssetVersion 是内嵌静态资源的内容指纹,用于 /static/*.css?v=... 的长缓存 AssetVersion string Query string + Name string + InlineError string + InvalidField string Next string DueToday int Due3 int diff --git a/internal/web/static/app.css b/internal/web/static/app.css index e10f7ab..4a2e058 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -1259,24 +1259,123 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } /* ---------- 客户自助查询 /customer ---------- */ -.portal-wrap { max-width: 980px; margin: 0 auto; padding: 24px 16px 48px; } -.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-form { border-radius: 1rem; } -.portal-progress .bar { height: 8px; border-radius: 999px; background: var(--app-surface-3); overflow: hidden; margin-bottom: 6px; } -.portal-progress .bar i { display: block; height: 100%; background: linear-gradient(135deg, var(--app-accent-strong), var(--app-accent)); border-radius: 999px; } -.pcal { border: 1px solid var(--app-border); border-radius: .85rem; overflow: hidden; } -.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-wrap { max-width: 980px; margin: 0 auto; padding: 24px 16px 56px; } +.portal-head { margin-bottom: 18px; } +.portal-brand { + display: inline-flex; align-items: center; gap: 8px; + margin-bottom: 12px; color: var(--app-text); text-decoration: none; + font-weight: 700; font-size: .9375rem; +} +.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-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 b { font-size: .82rem; color: var(--app-text); } -.pcal-cell span { font-size: .72rem; color: var(--app-accent); font-weight: 700; } -.pcal-cell.out { opacity: .4; } -.pcal-cell.today { background: var(--app-accent-soft); box-shadow: inset 0 0 0 2px var(--app-accent); } +.pcal-cell b { font-size: .8125rem; color: var(--app-text); } +.pcal-cell.out { background: var(--app-surface-2); } +.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 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) { - .portal-wrap { padding: 16px 10px 32px; } - .pcal-cell { min-height: 46px; padding: 4px 5px; } + .portal-wrap { padding: 16px 10px 40px; } + .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 { diff --git a/internal/web/templates/pages/portal.html b/internal/web/templates/pages/portal.html index fe2e278..11e7465 100644 --- a/internal/web/templates/pages/portal.html +++ b/internal/web/templates/pages/portal.html @@ -1,98 +1,123 @@ {{define "content"}}
-
-

排单查询

-

输入手机尾号查看自己的排单与完成情况{{if not .D}};同一尾号有多位客户时需要再填姓名{{end}}

-
+ 代打台 +

排单查询

+

输入手机尾号查看自己的排单与完成情况。

-
-
-
-
- - -
-
- - -
-
- -
-
-

只显示排单与完成情况,不含备注与价格。如有疑问请联系代打。

-
-
+ {{if .InlineError}}{{end}} - {{if .Error}}{{end}} + {{with .D}} - {{if .Names}} -
-

找到 {{.Count}} 位客户

-

同一尾号有多位客户,请在上方补填姓名(支持模糊匹配)后重新查询。

-
- {{else}} + {{if .Masked}}
-
-

{{.Tail}} · {{.Name}}

- {{if .Game}}{{.Game}}{{end}} - {{.ActiveSubs}} 个订阅 - {{if .Expiring}}最近到期 {{.Expiring}}{{end}} - - {{if .AllDone}}今日已清完 - {{else}}今日待完成 {{.TodayDone}} / {{.TodayTotal}}{{end}} - -
-
-
- 今日 {{.TodayDone}} / {{.TodayTotal}} 单完成 +

找到 {{.Count}} 位客户

+

尾号 {{.Tail}} 对应多位客户,请补一个姓名后查询。

+ {{if .Masked}}

可能是:{{range $i, $n := .Masked}}{{if $i}}、{{end}}{{$n}}{{end}}

{{end}} +
+ + + + +
+
+
+ {{else}} +
+ {{if .Subs}} +
+
+
+

{{.Tail}} · {{.Name}}

+
+ {{if .Game}}{{.Game}}{{end}} + {{if .Expiring}}最近到期 {{.Expiring}}{{end}} +
+
+
+ {{if eq .TodayState "done"}}今日已清完 + {{else if eq .TodayState "pending"}}今日待完成 {{.TodayPending}} / {{.TodayTotal}} + {{else}}今日无排单{{end}} +
- {{if .Subs}} -
- {{range .Subs}} -
-
-
- {{.Name}} +
+

订阅

{{.SubCount}}
+
+ {{range .Subs}} +
+
+ {{.PackageName}} {{.UnitLabel}} - {{if .TodayDone}}今日已完成{{else}}今日未完成{{end}} + {{if .TodayLabel}}{{.TodayLabel}}{{else}}{{template "state-chip" .}}{{end}}
-
{{.Period}}
-
+
+ {{.RemainText}} + {{.PeriodText}} +
+ + {{end}}
- {{end}} -
- {{end}} +
-
-

当月排单

-
- ‹ +
+

当月排单

+
+ ‹ {{.MonthTitle}} - › + ›
-
-
一二三四五六日
+
+
+ 一二三四五六日 +
{{range .Weeks}} -
+
{{range .}} -
+
{{.Day}} - {{if and .InMonth .Total}}{{.Done}}/{{.Total}}{{end}} + {{if .Today}}今天{{end}} + {{if .BlockText}}{{.BlockText}}{{end}} + {{if .Total}}{{if eq .Total 1}}{{else}}{{.Done}}/{{.Total}}{{end}}{{end}}
{{end}}
{{end}}
+ {{else}} +
+

{{.Tail}} · {{.Name}}

+
{{if .Game}}{{.Game}}{{end}}
+

还没有进行中的订阅。如需开通,请联系你的代打。

+
+ {{end}}
{{end}} {{end}} diff --git a/internal/web/templates/pages/settings.html b/internal/web/templates/pages/settings.html index 26b601e..9951b84 100644 --- a/internal/web/templates/pages/settings.html +++ b/internal/web/templates/pages/settings.html @@ -89,6 +89,7 @@
数据库
{{.D.DBPath}}
备份
{{.D.BackupDir}} · 保留 {{.D.BackupKeep}} 份 · {{if .D.BackupEnabled}}自动备份已开启{{else}}自动备份已关闭{{end}}
大屏
/screen {{if .D.ScreenPublic}}免登录{{else}}需登录{{end}}
+
客户自助
/customer {{if .D.PortalPublic}}免登录{{else}}已关闭{{end}}
局域网地址
http://{{if .D.Addrs}}{{index .D.Addrs 0}}{{else}}127.0.0.1{{end}}:{{.D.Port}}/screen
已运行
{{.D.Uptime}}
数据量
{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志
diff --git a/main.go b/main.go index 5dca588..6aef9bd 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,7 @@ import ( "daili/internal/web" ) -const version = "1.11.2" +const version = "1.11.3" func main() { cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")") @@ -131,6 +131,7 @@ func main() { BackupDir: cfg.BackupDir, BackupEnabled: cfg.BackupEnabled, ScreenPublic: cfg.ScreenPublic, + PortalPublic: cfg.PortalPublic, ConfigPath: cfg.Path, Version: version, })