Compare commits
5
Commits
8d9cf658a3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0392f44cf1 | ||
|
|
2e2d7d4bdd | ||
|
|
ddc7988f34 | ||
|
|
7371b86a97 | ||
|
|
c84175b723 |
@@ -19,7 +19,7 @@
|
|||||||
┌───────────────────────────────────────────────┐
|
┌───────────────────────────────────────────────┐
|
||||||
│ 代 打 台 · 代打管理平台 │
|
│ 代 打 台 · 代打管理平台 │
|
||||||
└───────────────────────────────────────────────┘
|
└───────────────────────────────────────────────┘
|
||||||
版本 1.11.1 配置文件 daili.ini
|
版本 1.11.5 配置文件 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,10 +155,19 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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。
|
||||||
- **深渊**:套餐填「周期天数 N」与「周期数 X」。开通时从开始日连续排出 X 个 N 天周期;独立日历按周期连续着色,每一格显示同一个「已完成周期 / 总周期数」。周期内任意一天完成即算该周期完成(同周期重复完成不会重复计数),撤销该天打卡会退回该周期。
|
- **深渊**:套餐填「周期天数 N」与「周期数 X」。开通时从开始日连续排出 X 个 N 天周期;独立日历按周期连续着色,颜色从约 34% 依次减淡到约 4%。每一格显示**该周期自己**的完成情况:`1/1` 或 `0/1`,不再重复全局的 `x/X`;工作台、大屏、客户自助页同一口径。周期内任意一天完成即算该周期完成(同周期重复完成不会重复计数),撤销该天打卡会退回该周期。
|
||||||
- **通行证过期不可选**:今天已过通行证结束日的套餐,在客户开通下拉里会置灰并标「通行证已结束」,服务端也会拒绝开通。
|
- **通行证过期不可选**:今天已过通行证结束日的套餐,在客户开通下拉里会置灰并标「通行证已结束」,服务端也会拒绝开通。
|
||||||
- **按次默认有效期**:按次套餐可设「默认有效期(天)」,开单时自动算出到期日(开始日 + 天数 − 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`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -446,15 +446,14 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
if cs.isBlock() {
|
if cs.isBlock() {
|
||||||
if cs.blockCovers(ds) && cell.BlockText == "" {
|
if cs.blockCovers(ds) && cell.BlockText == "" {
|
||||||
cell.BlockColor = cs.sub.PackageColor
|
cell.BlockColor = cs.sub.PackageColor
|
||||||
cell.BlockDone = cs.blockDone()
|
cell.BlockText = blockCellText(cs, ds)
|
||||||
cell.BlockTotal = cs.blockTotal()
|
cell.BlockDone = 0
|
||||||
cell.BlockText = fmt.Sprintf("%d/%d", cell.BlockDone, cell.BlockTotal)
|
if cell.BlockText == "1/1" {
|
||||||
cell.BlockTitle = cs.sub.PackageName + " · " + cell.BlockText
|
cell.BlockDone = 1
|
||||||
cell.BlockPercent = 18
|
|
||||||
if cs.sub.Abyss && cs.sub.CycleDays > 0 {
|
|
||||||
idx := domain.CycleIndex(cs.start, day, cs.sub.CycleDays)
|
|
||||||
cell.BlockPercent = abyssBlockPercent(idx, cs.sub.TotalCount)
|
|
||||||
}
|
}
|
||||||
|
cell.BlockTotal = 1
|
||||||
|
cell.BlockTitle = cs.sub.PackageName + " · " + cell.BlockText
|
||||||
|
cell.BlockPercent = blockCellPercent(cs, day)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -664,11 +663,35 @@ func assigneeStrip(tickets []Ticket) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// abyssBlockPercent 深渊色块浓度:第一个周期 22%,之后每个周期依次减淡,
|
// blockCellText 整段型订阅在 date 所在周期的完成文本:
|
||||||
// 最后一个周期 10%(周期数大于 1 时线性过渡)。
|
// 通行证 0/1 或 1/1;深渊按 date 所在的那一个周期 0/1 或 1/1,不显示全局 x/N。
|
||||||
|
func blockCellText(cs calSub, date string) string {
|
||||||
|
if cs.sub.Abyss {
|
||||||
|
if cs.cycleDone(date) {
|
||||||
|
return "1/1"
|
||||||
|
}
|
||||||
|
return "0/1"
|
||||||
|
}
|
||||||
|
if cs.sub.SeasonDone != "" {
|
||||||
|
return "1/1"
|
||||||
|
}
|
||||||
|
return "0/1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockCellPercent 整段型色块浓度:通行证 18%;深渊按所在周期 34%→4%。
|
||||||
|
func blockCellPercent(cs calSub, day time.Time) int {
|
||||||
|
if cs.sub.Abyss && cs.sub.CycleDays > 0 {
|
||||||
|
idx := domain.CycleIndex(cs.start, day, cs.sub.CycleDays)
|
||||||
|
return abyssBlockPercent(idx, cs.sub.TotalCount)
|
||||||
|
}
|
||||||
|
return 18
|
||||||
|
}
|
||||||
|
|
||||||
|
// abyssBlockPercent 深渊色块浓度:第一个周期 34%,之后每个周期依次减淡,
|
||||||
|
// 最后一个周期 4%(周期数大于 1 时线性过渡);跨度比 v1.11.3 的 22%→10% 明显更大。
|
||||||
func abyssBlockPercent(idx, total int) int {
|
func abyssBlockPercent(idx, total int) int {
|
||||||
if total <= 1 {
|
if total <= 1 {
|
||||||
return 22
|
return 34
|
||||||
}
|
}
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
idx = 0
|
idx = 0
|
||||||
@@ -676,9 +699,9 @@ func abyssBlockPercent(idx, total int) int {
|
|||||||
if idx >= total {
|
if idx >= total {
|
||||||
idx = total - 1
|
idx = total - 1
|
||||||
}
|
}
|
||||||
pct := 22 - (idx*12)/(total-1)
|
pct := 34 - (idx*30)/(total-1)
|
||||||
if pct < 10 {
|
if pct < 4 {
|
||||||
pct = 10
|
pct = 4
|
||||||
}
|
}
|
||||||
return pct
|
return pct
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
"daili/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// portalDay 客户自助月历中的一天:日常任务按 done/total 计,通行证/深渊按整段色块展示。
|
||||||
|
type portalDay struct {
|
||||||
|
Date string
|
||||||
|
Day int
|
||||||
|
InMonth bool
|
||||||
|
Today bool
|
||||||
|
Total int
|
||||||
|
Done int
|
||||||
|
// 通行证 / 深渊色块
|
||||||
|
BlockColor string
|
||||||
|
BlockText string
|
||||||
|
BlockTitle string
|
||||||
|
BlockPercent int
|
||||||
|
// Label 是读屏用的整格描述
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// portalSub 客户自助页里的一个订阅;复用管理端 SubView 的状态/剩余文案。
|
||||||
|
type portalSub struct {
|
||||||
|
SubView
|
||||||
|
TodayLabel string
|
||||||
|
TodayClass string
|
||||||
|
}
|
||||||
|
|
||||||
|
// portalAsk 同一尾号命中多个客户时要求补姓名。
|
||||||
|
type portalAsk struct {
|
||||||
|
Tail string
|
||||||
|
Count int
|
||||||
|
Masked []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// portalView 客户自助查询结果。
|
||||||
|
type portalView struct {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
p.InlineError = "没有找到这个尾号的客户,请核对尾号"
|
||||||
|
p.InvalidField = "tail"
|
||||||
|
s.render(w, r, "portal", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(list) > 1 {
|
||||||
|
if name == "" {
|
||||||
|
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.InlineError = "尾号对得上,但姓名不匹配,请再确认一下"
|
||||||
|
p.InvalidField = "name"
|
||||||
|
s.render(w, r, "portal", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(matched) > 1 {
|
||||||
|
p.D = &portalAsk{Tail: tail, Count: len(matched), Masked: maskNames(matched)}
|
||||||
|
s.render(w, r, "portal", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list = matched
|
||||||
|
}
|
||||||
|
|
||||||
|
c := list[0]
|
||||||
|
p.D = s.buildPortal(c, tail, name, month)
|
||||||
|
s.render(w, r, "portal", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchByName(list []store.Customer, name string) []store.Customer {
|
||||||
|
needle := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
out := make([]store.Customer, 0, len(list))
|
||||||
|
for _, c := range list {
|
||||||
|
if strings.Contains(strings.ToLower(c.Name), needle) {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
first = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
year, mon := first.Year(), int(first.Month())
|
||||||
|
firstDay := time.Date(year, time.Month(mon), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
lastDay := firstDay.AddDate(0, 1, -1)
|
||||||
|
start := firstDay.AddDate(0, 0, -((int(firstDay.Weekday()) + 6) % 7))
|
||||||
|
end := lastDay.AddDate(0, 0, 6-((int(lastDay.Weekday())+6)%7))
|
||||||
|
|
||||||
|
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 {
|
||||||
|
cs := calSub{
|
||||||
|
sub: sub,
|
||||||
|
start: domain.ParseDateOr(sub.StartDate, today),
|
||||||
|
eval: sub.Eval(today, warn),
|
||||||
|
pauses: pauses[sub.ID],
|
||||||
|
checkins: cks[sub.ID],
|
||||||
|
}
|
||||||
|
if sub.EndDate != "" {
|
||||||
|
cs.hasEnd = true
|
||||||
|
cs.end = domain.ParseDateOr(sub.EndDate, today)
|
||||||
|
}
|
||||||
|
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 != "" && 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 月历
|
||||||
|
days := int(end.Sub(start).Hours()/24) + 1
|
||||||
|
week := make([]portalDay, 0, 7)
|
||||||
|
for i := 0; i < days; i++ {
|
||||||
|
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}
|
||||||
|
// 与主日历一致:只在本月格子里显示任务,前后月只做日期占位。
|
||||||
|
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 = blockCellText(cs, ds)
|
||||||
|
pd.BlockTitle = sub.PackageName + " · " + pd.BlockText
|
||||||
|
pd.BlockPercent = blockCellPercent(cs, d)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !cs.covers(ds) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pd.Total++
|
||||||
|
if ci, ok := cks[sub.ID][ds]; ok && portalDone(ci) {
|
||||||
|
pd.Done++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pd.Label = portalDayLabel(pd, mon)
|
||||||
|
week = append(week, pd)
|
||||||
|
if len(week) == 7 {
|
||||||
|
v.Weeks = append(v.Weeks, week)
|
||||||
|
week = make([]portalDay, 0, 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(week) > 0 {
|
||||||
|
v.Weeks = append(v.Weeks, week)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 今日进度:整段型订阅算 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()
|
||||||
|
}
|
||||||
@@ -21,6 +21,11 @@ type screenDay struct {
|
|||||||
Done int
|
Done int
|
||||||
Urgent int
|
Urgent int
|
||||||
Heat int
|
Heat int
|
||||||
|
// 通行证 / 深渊整段色块:BlockText 是该周期的 1/1 或 0/1
|
||||||
|
BlockColor string
|
||||||
|
BlockText string
|
||||||
|
BlockTitle string
|
||||||
|
BlockPercent int
|
||||||
// Strip 打手色竖条(去重)
|
// Strip 打手色竖条(去重)
|
||||||
Strip []string
|
Strip []string
|
||||||
}
|
}
|
||||||
@@ -66,7 +71,13 @@ func (s *Server) screenCalSubs(from, to string, pkgID int64, tag string) ([]calS
|
|||||||
warn := s.warnDays()
|
warn := s.warnDays()
|
||||||
list := make([]calSub, 0, len(subs))
|
list := make([]calSub, 0, len(subs))
|
||||||
for _, sub := range subs {
|
for _, sub := range subs {
|
||||||
cs := calSub{sub: sub, pauses: pauses[sub.ID], checkins: checkins[sub.ID], eval: sub.Eval(today, warn)}
|
cs := calSub{
|
||||||
|
sub: sub,
|
||||||
|
start: domain.ParseDateOr(sub.StartDate, today),
|
||||||
|
pauses: pauses[sub.ID],
|
||||||
|
checkins: checkins[sub.ID],
|
||||||
|
eval: sub.Eval(today, warn),
|
||||||
|
}
|
||||||
if sub.EndDate != "" {
|
if sub.EndDate != "" {
|
||||||
cs.hasEnd = true
|
cs.hasEnd = true
|
||||||
cs.end = domain.ParseDateOr(sub.EndDate, today)
|
cs.end = domain.ParseDateOr(sub.EndDate, today)
|
||||||
@@ -138,15 +149,30 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
|||||||
count, doneN, urgentN := 0, 0, 0
|
count, doneN, urgentN := 0, 0, 0
|
||||||
strip := []string{}
|
strip := []string{}
|
||||||
seenColor := map[string]bool{}
|
seenColor := map[string]bool{}
|
||||||
|
sd := screenDay{
|
||||||
|
Date: ds,
|
||||||
|
Day: d.Day(),
|
||||||
|
InMonth: d.Month() == today.Month(),
|
||||||
|
IsToday: ds == domain.Date(today),
|
||||||
|
IsPast: d.Before(today),
|
||||||
|
}
|
||||||
for _, cs := range list {
|
for _, cs := range list {
|
||||||
if heatMode == "due" {
|
if heatMode == "due" {
|
||||||
if cs.sub.EndDate == ds {
|
if cs.sub.EndDate == ds {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// 通行证 / 深渊是整段型:不计入每日待完成热力,但用整段色块显示周期状态。
|
||||||
|
if cs.isBlock() {
|
||||||
|
if cs.blockCovers(ds) && sd.BlockText == "" {
|
||||||
|
sd.BlockColor = cs.sub.PackageColor
|
||||||
|
sd.BlockText = blockCellText(cs, ds)
|
||||||
|
sd.BlockTitle = cs.sub.PackageName + " · " + sd.BlockText
|
||||||
|
sd.BlockPercent = blockCellPercent(cs, d)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 通行证 / 深渊是整段型,不计入「每日待完成」热力
|
if heatMode == "due" {
|
||||||
if cs.isBlock() {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !cs.covers(ds) {
|
if !cs.covers(ds) {
|
||||||
@@ -173,18 +199,12 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
|||||||
if d.Month() == today.Month() {
|
if d.Month() == today.Month() {
|
||||||
data.HeatTotal += count
|
data.HeatTotal += count
|
||||||
}
|
}
|
||||||
week = append(week, screenDay{
|
sd.Count = count + doneN
|
||||||
Date: ds,
|
sd.Done = doneN
|
||||||
Day: d.Day(),
|
sd.Urgent = urgentN
|
||||||
InMonth: d.Month() == today.Month(),
|
sd.Heat = heatLevel(count)
|
||||||
IsToday: ds == domain.Date(today),
|
sd.Strip = strip
|
||||||
IsPast: d.Before(today),
|
week = append(week, sd)
|
||||||
Count: count + doneN,
|
|
||||||
Done: doneN,
|
|
||||||
Urgent: urgentN,
|
|
||||||
Heat: heatLevel(count),
|
|
||||||
Strip: strip,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
data.Weeks = append(data.Weeks, week)
|
data.Weeks = append(data.Weeks, week)
|
||||||
}
|
}
|
||||||
@@ -214,16 +234,9 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
|||||||
data.Recent = adj
|
data.Recent = adj
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页签:按标签合并 + 独立显示另开;通行证/深渊不计每日热力,不放大屏页签
|
// 页签:按标签合并 + 独立显示另开;通行证 / 深渊也放进大屏,用整段色块显示周期状态
|
||||||
pkgs, _ := s.db.ListPackages(true)
|
pkgs, _ := s.db.ListPackages(true)
|
||||||
screenPkgs := make([]store.Package, 0, len(pkgs))
|
data.Tabs, data.HasTabs = buildCalendarTabs(pkgs, nil, 0)
|
||||||
for _, p := range pkgs {
|
|
||||||
if p.Season || p.Abyss {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
screenPkgs = append(screenPkgs, p)
|
|
||||||
}
|
|
||||||
data.Tabs, data.HasTabs = buildCalendarTabs(screenPkgs, nil, 0)
|
|
||||||
for i := range data.Tabs {
|
for i := range data.Tabs {
|
||||||
t := &data.Tabs[i]
|
t := &data.Tabs[i]
|
||||||
href := "/screen?heat=" + heatMode
|
href := "/screen?heat=" + heatMode
|
||||||
|
|||||||
@@ -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,6 +155,10 @@ 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 /screen", s.handleScreen)
|
mux.HandleFunc("GET /screen", s.handleScreen)
|
||||||
@@ -371,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
|
||||||
|
|||||||
@@ -1256,3 +1256,131 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- 客户自助查询 /customer ---------- */
|
||||||
|
.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 {
|
||||||
|
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: .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 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 {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 1px 8px; border-radius: 999px;
|
||||||
|
background: var(--app-accent-soft); border: 1px solid var(--app-border);
|
||||||
|
color: var(--app-accent-ink); font-size: .72rem; font-weight: 600; line-height: 1.7;
|
||||||
|
}
|
||||||
|
|||||||
@@ -210,6 +210,18 @@ body.screen-body::before {
|
|||||||
.heat-cell.h4 { background: color-mix(in srgb, var(--scr-accent) 52%, var(--scr-panel-2)); }
|
.heat-cell.h4 { background: color-mix(in srgb, var(--scr-accent) 52%, var(--scr-panel-2)); }
|
||||||
.heat-cell.h3 span, .heat-cell.h4 span { color: var(--scr-text); }
|
.heat-cell.h3 span, .heat-cell.h4 span { color: var(--scr-text); }
|
||||||
.heat-cell.h3 b, .heat-cell.h4 b { color: var(--scr-text); }
|
.heat-cell.h3 b, .heat-cell.h4 b { color: var(--scr-text); }
|
||||||
|
.heat-cell.has-block {
|
||||||
|
background: color-mix(in srgb, var(--block) var(--block-pct, 18%), var(--scr-panel-2));
|
||||||
|
border-color: color-mix(in srgb, var(--block) 30%, var(--scr-line));
|
||||||
|
}
|
||||||
|
.heat-cell b.scr-block {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
padding: 1px 5px; border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--block) 20%, var(--scr-panel));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--block) 32%, transparent);
|
||||||
|
color: var(--scr-text);
|
||||||
|
font-size: 12px; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.cell-urgent {
|
.cell-urgent {
|
||||||
position: absolute; top: 3px; right: 3px;
|
position: absolute; top: 3px; right: 3px;
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="portal-wrap">
|
||||||
|
<header class="portal-head">
|
||||||
|
<a class="portal-brand" href="/customer"><span class="brand-mark" aria-hidden="true">代</span><span>代打台</span></a>
|
||||||
|
<h1>排单查询</h1>
|
||||||
|
<p class="muted">输入手机尾号查看自己的排单与完成情况。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .InlineError}}<div class="notice notice-danger portal-error" id="portal-error" role="alert">{{.InlineError}}</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}}
|
||||||
|
{{if .Masked}}
|
||||||
|
<section class="portal-result">
|
||||||
|
<div class="card mt-3"><div class="card-body">
|
||||||
|
<h2 class="h5">找到 {{.Count}} 位客户</h2>
|
||||||
|
<p class="muted mb-2">尾号 {{.Tail}} 对应多位客户,请补一个姓名后查询。</p>
|
||||||
|
{{if .Masked}}<p class="portal-ask-names">可能是:{{range $i, $n := .Masked}}{{if $i}}、{{end}}{{$n}}{{end}}</p>{{end}}
|
||||||
|
<form class="portal-ask-form" method="get" action="/customer">
|
||||||
|
<input type="hidden" name="tail" value="{{.Tail}}">
|
||||||
|
<label class="visually-hidden" for="ask-name">姓名</label>
|
||||||
|
<input class="form-control" id="ask-name" name="name" maxlength="32" placeholder="输入姓名(可只填一部分)" required autofocus>
|
||||||
|
<button class="btn btn-primary" type="submit">查询</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
</section>
|
||||||
|
{{else}}
|
||||||
|
<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 class="card mt-3"><div class="card-body">
|
||||||
|
<div class="portal-section-head"><h2>订阅</h2><span class="portal-section-count">{{.SubCount}}</span></div>
|
||||||
|
<div class="portal-sub-list">
|
||||||
|
{{range .Subs}}
|
||||||
|
<article class="portal-sub">
|
||||||
|
<div class="portal-sub-main">
|
||||||
|
<strong>{{.PackageName}}</strong>
|
||||||
|
<span class="tagchip">{{.UnitLabel}}</span>
|
||||||
|
{{if .TodayLabel}}<span class="chip chip-{{.TodayClass}}">{{.TodayLabel}}</span>{{else}}{{template "state-chip" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="portal-sub-meta">
|
||||||
|
<span>{{.RemainText}}</span>
|
||||||
|
<span class="muted">{{.PeriodText}}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card mt-3"><div class="card-body">
|
||||||
|
<div class="portal-section-head">
|
||||||
|
<h2>当月排单</h2>
|
||||||
|
<div class="pcal-nav">
|
||||||
|
<a class="btn btn-soft pcal-nav-btn" href="{{.Prev}}" aria-label="上个月">‹</a>
|
||||||
|
<b>{{.MonthTitle}}</b>
|
||||||
|
<a class="btn btn-soft pcal-nav-btn" href="{{.Next}}" aria-label="下个月">›</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pcal" role="grid" aria-label="{{.MonthTitle}}排单">
|
||||||
|
<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}}
|
||||||
|
<div class="pcal-row" role="row">
|
||||||
|
{{range .}}
|
||||||
|
<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>
|
||||||
|
{{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>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</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>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -51,12 +51,12 @@
|
|||||||
{{range .D.Weeks}}
|
{{range .D.Weeks}}
|
||||||
<div class="heat-row">
|
<div class="heat-row">
|
||||||
{{range .}}
|
{{range .}}
|
||||||
<div class="heat-cell h{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsPast}} past{{end}}"
|
<div class="heat-cell h{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsPast}} past{{end}}{{if .BlockColor}} has-block{{end}}"
|
||||||
data-date="{{.Date}}" title="{{.Date}}">
|
data-date="{{.Date}}" title="{{if .BlockTitle}}{{.BlockTitle}} · {{end}}{{.Date}}"{{if .BlockColor}} style="--block:{{.BlockColor}};--block-pct:{{.BlockPercent}}%"{{end}}>
|
||||||
{{if .Strip}}<span class="asg-strip sm" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
|
{{if .Strip}}<span class="asg-strip sm" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
|
||||||
<span>{{.Day}}</span>
|
<span>{{.Day}}</span>
|
||||||
{{if gt .Urgent 0}}<u class="cell-urgent"></u>{{end}}
|
{{if gt .Urgent 0}}<u class="cell-urgent"></u>{{end}}
|
||||||
{{if .Count}}<b>{{.Done}}/{{.Count}}</b>{{end}}
|
{{if and .BlockText (not .Count)}}<b class="scr-block">{{.BlockText}}</b>{{else if .Count}}<b>{{.Done}}/{{.Count}}</b>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import (
|
|||||||
"daili/internal/web"
|
"daili/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "1.11.1"
|
const version = "1.11.5"
|
||||||
|
|
||||||
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,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user